diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..b1655551 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,126 @@ +# rfl::cli — Command-Line Argument Parser + +Parse `argc`/`argv` into any reflectable struct via `rfl::cli::read(argc, argv)`. + +## Usage + +```cpp +#include + +struct Config { + std::string host_name; + int port; + bool verbose; + std::optional rate; + std::vector tags; +}; + +int main(int argc, char* argv[]) { + const auto result = rfl::cli::read(argc, argv); + // ./app --host-name=localhost --port=8080 --verbose --tags=a,b,c +} +``` + +Field names undergo automatic `snake_case` -> `kebab-case` conversion: +`host_name` matches `--host-name`. + +## Positional arguments + +Wrap a field with `rfl::Positional` to accept it as a bare (non-flag) argument: + +```cpp +struct Config { + rfl::Positional input_file; + rfl::Positional output_file; + bool verbose; +}; + +// ./app input.txt output.txt --verbose +``` + +Positional arguments are matched in declaration order. They can also be +passed as named arguments: `--input-file=input.txt`. + +The `--` separator forces all subsequent tokens into positional: + +``` +./app --verbose -- --not-a-flag.txt +``` + +## Short aliases + +Wrap a field with `rfl::Short<"x", T>` to add a single-character alias: + +```cpp +struct Config { + rfl::Short<"p", int> port; + rfl::Short<"v", bool> verbose; + std::string host; +}; + +// ./app -p 8080 -v --host=localhost +// ./app -p=8080 -v --host=localhost +// ./app --port=8080 --verbose --host=localhost (long names still work) +``` + +Short bool flags do not consume the next token as a value — `-v somefile` +treats `somefile` as a positional argument, not as the value of `-v`. +To explicitly set a bool short flag, use `=` syntax: `-v=true`, `-v=false`. + +## Combining Positional and Short + +`Positional` and `Short` can be used together in the same struct, but +**cannot be nested** (`Positional>` is a compile-time error): + +```cpp +struct Config { + rfl::Positional input_file; + rfl::Short<"o", std::string> output_dir; + rfl::Short<"v", bool> verbose; + int count; +}; + +// ./app data.csv -o /tmp/out -v --count=10 +``` + +## Supported types + +| Type | CLI format | Notes | +|------|-----------|-------| +| `std::string` | `--key=value` | | +| `int`, `long`, ... | `--key=42` | | +| `float`, `double` | `--key=1.5` | | +| `bool` | `--flag` or `--flag=true` | No `=` implies `true` | +| `enum` | `--key=value_name` | Via `rfl::string_to_enum` | +| `std::optional` | omit for `nullopt` | | +| `std::vector` | `--key=a,b,c` | Comma-separated; empty elements skipped | +| Nested struct | `--parent.child=val` | Dot-separated path | +| `rfl::Flatten` | fields inlined | No prefix needed | +| `rfl::Rename<"x", T>` | `--x=val` | Bypasses kebab conversion | +| `rfl::Positional` | bare token | Matched in declaration order | +| `rfl::Short<"x", T>` | `-x value` or `-x=value` | Single-character alias | + +## Architecture + +Parsing proceeds in three stages: + +1. **`parse_argv`** — categorizes raw tokens into `named`, `short_args`, + and `positional` buckets (`ParsedArgs` struct). No type information needed. +2. **`resolve_args`** — uses compile-time metadata from the target struct to + map short aliases to long names, reclaim values from bool short flags, + and merge positional arguments. Produces a flat `map`. +3. **`Reader`** — implements reflect-cpp's `IsReader` concept by presenting + virtual tree nodes over the flat map. Each node is a `{map*, path}` pair — + no data copying, just prefix-based lookup via `lower_bound`. + +## Files + +- `include/rfl/cli/read.hpp` — public API +- `include/rfl/cli/Reader.hpp` — Reader + `parse_value` overloads +- `include/rfl/cli/Parser.hpp` — Parser type alias +- `include/rfl/cli/parse_argv.hpp` — `argv` -> `ParsedArgs` +- `include/rfl/cli/resolve_args.hpp` — `ParsedArgs` -> `map` +- `include/rfl/cli.hpp` — aggregator header +- `include/rfl/SnakeCaseToKebabCase.hpp` — processor +- `include/rfl/Positional.hpp` — `Positional` wrapper +- `include/rfl/Short.hpp` — `Short<"x", T>` wrapper diff --git a/include/rfl.hpp b/include/rfl.hpp index b57542b6..ab164bab 100644 --- a/include/rfl.hpp +++ b/include/rfl.hpp @@ -34,12 +34,15 @@ #include "rfl/OneOf.hpp" #include "rfl/Pattern.hpp" #include "rfl/PatternValidator.hpp" +#include "rfl/Positional.hpp" #include "rfl/Processors.hpp" #include "rfl/Ref.hpp" #include "rfl/Rename.hpp" +#include "rfl/Short.hpp" #include "rfl/Size.hpp" #include "rfl/Skip.hpp" #include "rfl/SnakeCaseToCamelCase.hpp" +#include "rfl/SnakeCaseToKebabCase.hpp" #include "rfl/SnakeCaseToPascalCase.hpp" #include "rfl/CamelCaseToSnakeCase.hpp" #include "rfl/TaggedUnion.hpp" diff --git a/include/rfl/Positional.hpp b/include/rfl/Positional.hpp new file mode 100644 index 00000000..40220f40 --- /dev/null +++ b/include/rfl/Positional.hpp @@ -0,0 +1,125 @@ +#ifndef RFL_POSITIONAL_HPP_ +#define RFL_POSITIONAL_HPP_ + +#include +#include + +#include "default.hpp" + +namespace rfl { + +/// Marks a field as positional for CLI argument parsing. +/// For non-CLI formats (JSON, YAML, etc.), this is transparent. +template +struct Positional { + /// The underlying type. + using Type = T; + + Positional() requires std::is_default_constructible_v + : value_(Type()) {} + + Positional(const Type& _value) : value_(_value) {} + + Positional(Type&& _value) noexcept : value_(std::move(_value)) {} + + Positional(Positional&& _field) noexcept = default; + + Positional(const Positional& _field) = default; + + template + Positional(const Positional& _field) : value_(_field.get()) {} + + template + Positional(Positional&& _field) : value_(std::move(_field.value_)) {} + + template + requires std::is_convertible_v + Positional(const U& _value) : value_(_value) {} + + template + requires std::is_convertible_v + Positional(U&& _value) noexcept : value_(std::forward(_value)) {} + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + Positional(const Default&) : value_(Type()) {} + + ~Positional() = default; + + /// Returns the underlying object. + const Type& get() const { return value_; } + + /// Returns the underlying object. + Type& operator()() { return value_; } + + /// Returns the underlying object. + const Type& operator()() const { return value_; } + + /// Assigns the underlying object. + auto& operator=(const Type& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object. + auto& operator=(Type&& _value) noexcept { + value_ = std::move(_value); + return *this; + } + + /// Assigns the underlying object. + template + requires std::is_convertible_v + auto& operator=(const U& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + auto& operator=(const Default&) { + value_ = Type(); + return *this; + } + + /// Assigns the underlying object. + Positional& operator=(const Positional& _field) = default; + + /// Assigns the underlying object. + Positional& operator=(Positional&& _field) = default; + + /// Assigns the underlying object. + template + auto& operator=(const Positional& _field) { + value_ = _field.get(); + return *this; + } + + /// Assigns the underlying object. + template + auto& operator=(Positional&& _field) { + value_ = std::move(_field.value_); + return *this; + } + + /// Assigns the underlying object. + void set(const Type& _value) { value_ = _value; } + + /// Assigns the underlying object. + void set(Type&& _value) { value_ = std::move(_value); } + + /// Returns the underlying object. + Type& value() { return value_; } + + /// Returns the underlying object. + const Type& value() const { return value_; } + + /// The underlying value. + Type value_; +}; + +} // namespace rfl + +#endif diff --git a/include/rfl/Short.hpp b/include/rfl/Short.hpp new file mode 100644 index 00000000..c1f6424d --- /dev/null +++ b/include/rfl/Short.hpp @@ -0,0 +1,131 @@ +#ifndef RFL_SHORT_HPP_ +#define RFL_SHORT_HPP_ + +#include +#include + +#include "default.hpp" +#include "internal/StringLiteral.hpp" + +namespace rfl { + +/// Assigns a short CLI alias (e.g. "-p") to a field. +/// For non-CLI formats (JSON, YAML, etc.), this is transparent. +template +struct Short { + /// The underlying type. + using Type = T; + + /// The short name. + static constexpr auto short_name_ = _name; + + static_assert(_name.length == 1, "Short name must be exactly one character."); + + Short() requires std::is_default_constructible_v + : value_(Type()) {} + + Short(const Type& _value) : value_(_value) {} + + Short(Type&& _value) noexcept : value_(std::move(_value)) {} + + Short(Short<_name, T>&& _field) noexcept = default; + + Short(const Short<_name, T>& _field) = default; + + template + Short(const Short<_name, U>& _field) : value_(_field.get()) {} + + template + Short(Short<_name, U>&& _field) : value_(std::move(_field.value_)) {} + + template + requires std::is_convertible_v + Short(const U& _value) : value_(_value) {} + + template + requires std::is_convertible_v + Short(U&& _value) noexcept : value_(std::forward(_value)) {} + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + Short(const Default&) : value_(Type()) {} + + ~Short() = default; + + /// Returns the underlying object. + const Type& get() const { return value_; } + + /// Returns the underlying object. + Type& operator()() { return value_; } + + /// Returns the underlying object. + const Type& operator()() const { return value_; } + + /// Assigns the underlying object. + auto& operator=(const Type& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object. + auto& operator=(Type&& _value) noexcept { + value_ = std::move(_value); + return *this; + } + + /// Assigns the underlying object. + template + requires std::is_convertible_v + auto& operator=(const U& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + auto& operator=(const Default&) { + value_ = Type(); + return *this; + } + + /// Assigns the underlying object. + Short<_name, T>& operator=(const Short<_name, T>& _field) = default; + + /// Assigns the underlying object. + Short<_name, T>& operator=(Short<_name, T>&& _field) = default; + + /// Assigns the underlying object. + template + auto& operator=(const Short<_name, U>& _field) { + value_ = _field.get(); + return *this; + } + + /// Assigns the underlying object. + template + auto& operator=(Short<_name, U>&& _field) { + value_ = std::move(_field.value_); + return *this; + } + + /// Assigns the underlying object. + void set(const Type& _value) { value_ = _value; } + + /// Assigns the underlying object. + void set(Type&& _value) { value_ = std::move(_value); } + + /// Returns the underlying object. + Type& value() { return value_; } + + /// Returns the underlying object. + const Type& value() const { return value_; } + + /// The underlying value. + Type value_; +}; + +} // namespace rfl + +#endif diff --git a/include/rfl/SnakeCaseToKebabCase.hpp b/include/rfl/SnakeCaseToKebabCase.hpp new file mode 100644 index 00000000..2350e8f8 --- /dev/null +++ b/include/rfl/SnakeCaseToKebabCase.hpp @@ -0,0 +1,39 @@ +#ifndef RFL_SNAKECASETOKEBABCASE_HPP_ +#define RFL_SNAKECASETOKEBABCASE_HPP_ + +#include "Field.hpp" +#include "internal/is_rename.hpp" +#include "internal/transform_case.hpp" + +namespace rfl { + +struct SnakeCaseToKebabCase { + public: + /// Replaces all instances of snake_case field names with kebab-case. + template + static auto process(const auto& _named_tuple) { + return _named_tuple.transform([](const FieldType& _f) { + // "xml_content" is a reserved name used by the XML Reader/Writer; + // all case-transform processors must leave it unchanged. + if constexpr (FieldType::name() != "xml_content" && + !internal::is_rename_v) { + return handle_one_field(_f); + } else { + return _f; + } + }); + } + + private: + template + static auto handle_one_field(const FieldType& _f) { + using NewFieldType = + Field(), + typename FieldType::Type>; + return NewFieldType(_f.value()); + } +}; + +} // namespace rfl + +#endif diff --git a/include/rfl/cli.hpp b/include/rfl/cli.hpp new file mode 100644 index 00000000..763f335b --- /dev/null +++ b/include/rfl/cli.hpp @@ -0,0 +1,11 @@ +#ifndef RFL_CLI_HPP_ +#define RFL_CLI_HPP_ + +#include "../rfl.hpp" +#include "cli/Parser.hpp" +#include "cli/Reader.hpp" +#include "cli/parse_argv.hpp" +#include "cli/read.hpp" +#include "cli/resolve_args.hpp" + +#endif diff --git a/include/rfl/cli/Parser.hpp b/include/rfl/cli/Parser.hpp new file mode 100644 index 00000000..ae82964d --- /dev/null +++ b/include/rfl/cli/Parser.hpp @@ -0,0 +1,15 @@ +#ifndef RFL_CLI_PARSER_HPP_ +#define RFL_CLI_PARSER_HPP_ + +#include "../generic/Writer.hpp" +#include "../parsing/Parser.hpp" +#include "Reader.hpp" + +namespace rfl::cli { + +template +using Parser = parsing::Parser; + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/cli/Reader.hpp b/include/rfl/cli/Reader.hpp new file mode 100644 index 00000000..451c8a00 --- /dev/null +++ b/include/rfl/cli/Reader.hpp @@ -0,0 +1,269 @@ +#ifndef RFL_CLI_READER_HPP_ +#define RFL_CLI_READER_HPP_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../Result.hpp" +#include "../always_false.hpp" + +namespace rfl::cli { + +static constexpr char path_separator = '.'; +static constexpr char array_delimiter = ','; + +struct CliVarType { + const std::map* const args = nullptr; + const std::string path; + const std::optional direct_value; +}; + +struct CliObjectType { + const std::map* const args = nullptr; + const std::string prefix; +}; + +struct CliArrayType { + const std::vector values; +}; + +// --- Constrained overloads for string-to-type parsing --- + +template requires std::same_as +rfl::Result parse_value( + const std::string& _str, const std::string& +) noexcept { + return _str; +} + +template requires std::same_as +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + if (_str.empty() || _str == "true" || _str == "1") { + return true; + } + if (_str == "false" || _str == "0") { + return false; + } + return error( + "Could not cast '" + _str + "' to boolean for key '" + _path + "'."); +} + +template requires (std::is_floating_point_v) +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + try { + return static_cast(std::stod(_str)); + } + catch (...) { + return error( + "Could not cast '" + _str + "' to floating point for key '" + _path + "'."); + } +} + +template requires (std::is_unsigned_v && !std::same_as) +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + try { + return static_cast(std::stoull(_str)); + } + catch (...) { + return error( + "Could not cast '" + _str + "' to unsigned integer for key '" + _path + "'."); + } +} + +template requires (std::is_integral_v && std::is_signed_v) +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + try { + return static_cast(std::stoll(_str)); + } + catch (...) { + return error( + "Could not cast '" + _str + "' to integer for key '" + _path + "'."); + } +} + +struct Reader { + using InputArrayType = CliArrayType; + using InputObjectType = CliObjectType; + using InputVarType = CliVarType; + + template + static constexpr bool has_custom_constructor = false; + + rfl::Result get_field_from_array( + const size_t _idx, const InputArrayType& _arr) const noexcept { + if (_idx >= _arr.values.size()) { + return error( + std::string("Index ") + std::to_string(_idx) + " out of bounds."); + } + return InputVarType{nullptr, "", _arr.values[_idx]}; + } + + rfl::Result get_field_from_object( + const std::string& _name, const InputObjectType& _obj) const noexcept { + const auto child_path = _obj.prefix.empty() + ? _name + : _obj.prefix + _name; + return InputVarType{_obj.args, child_path, std::nullopt}; + } + + bool is_empty(const InputVarType& _var) const noexcept { + if (_var.direct_value) { + return false; + } + if (!_var.args) { + return true; + } + if (_var.args->count(_var.path)) { + return false; + } + const auto prefix = _var.path + path_separator; + const auto it = _var.args->lower_bound(prefix); + return it == _var.args->end() + || it->first.substr(0, prefix.size()) != prefix; + } + + template + std::optional read_array( + const ArrayReader& _array_reader, + const InputArrayType& _arr + ) const noexcept { + for (const auto& val : _arr.values) { + const auto err = _array_reader.read( + InputVarType{nullptr, "", val}); + if (err) { + return err; + } + } + return std::nullopt; + } + + template + std::optional read_object( + const ObjectReader& _object_reader, + const InputObjectType& _obj + ) const noexcept { + std::set seen; + auto it = _obj.prefix.empty() + ? _obj.args->begin() + : _obj.args->lower_bound(_obj.prefix); + while (it != _obj.args->end()) { + if (!_obj.prefix.empty() + && it->first.substr(0, _obj.prefix.size()) != _obj.prefix) { + break; + } + const auto rest = std::string_view(it->first).substr(_obj.prefix.size()); + const auto separator_pos = rest.find(path_separator); + const auto child = std::string( + separator_pos == std::string_view::npos + ? rest + : rest.substr(0, separator_pos)); + if (!child.empty() && seen.insert(child).second) { + const auto child_path = _obj.prefix + child; + _object_reader.read( + std::string_view(child), + InputVarType{_obj.args, child_path, std::nullopt}); + } + ++it; + } + return std::nullopt; + } + + template + rfl::Result to_basic_type(const InputVarType& _var) const noexcept { + const auto str = get_value(_var); + if (!str) { + return error("No value for key '" + _var.path + "'."); + } + return parse_value(*str, _var.path); + } + + rfl::Result to_array( + const InputVarType& _var) const noexcept { + const auto str = get_value(_var); + if (!str) { + return InputArrayType{{}}; + } + return InputArrayType{split(*str, array_delimiter)}; + } + + rfl::Result to_object( + const InputVarType& _var) const noexcept { + if (!_var.args) { + return error("Cannot convert to object: no argument map available" + + (_var.path.empty() + ? std::string(".") + : " for key '" + _var.path + "'.")); + } + const auto prefix = _var.path.empty() + ? std::string("") + : _var.path + path_separator; + return InputObjectType{_var.args, prefix}; + } + + template + rfl::Result use_custom_constructor( + const InputVarType& + ) const noexcept { + return error("Custom constructors are not supported for CLI parsing."); + } + + private: + static std::optional get_value( + const InputVarType& _var + ) noexcept { + if (_var.direct_value) { + return *_var.direct_value; + } + if (!_var.args) { + return std::nullopt; + } + const auto it = _var.args->find(_var.path); + if (it == _var.args->end()) { + return std::nullopt; + } + return it->second; + } + + static std::vector split( + const std::string& _str, char _delim + ) { + std::vector result; + if (_str.empty()) { + return result; + } + size_t start = 0; + while (true) { + const auto pos = _str.find(_delim, start); + if (pos == std::string::npos) { + if (start < _str.size()) { + result.emplace_back(_str.substr(start)); + } + break; + } + if (pos > start) { + result.emplace_back(_str.substr(start, pos - start)); + } + start = pos + 1; + } + return result; + } + +}; + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/cli/parse_argv.hpp b/include/rfl/cli/parse_argv.hpp new file mode 100644 index 00000000..a8b0d13a --- /dev/null +++ b/include/rfl/cli/parse_argv.hpp @@ -0,0 +1,114 @@ +#ifndef RFL_CLI_PARSE_ARGV_HPP_ +#define RFL_CLI_PARSE_ARGV_HPP_ + +#include +#include +#include +#include +#include +#include + +#include "../Result.hpp" +#include "resolve_args.hpp" + +namespace rfl::cli { + +/// Returns true if the token looks like a CLI option (starts with '-' +/// followed by a letter), as opposed to a negative number like "-42". +inline bool looks_like_option(std::string_view _token) noexcept { + return _token.size() >= 2 + && _token[0] == '-' + && !std::isdigit(static_cast(_token[1])) + && _token[1] != '.'; +} + +/// Parses command-line arguments into categorized buckets: +/// --key=value / --flag → named +/// -x value / -x=value / -x → short_args +/// bare arguments → positional +/// -- → everything after goes to positional +inline rfl::Result parse_argv(int argc, char* argv[]) { + if (argc < 0 || (argc > 0 && !argv)) { + return error("Invalid argc/argv."); + } + ParsedArgs result; + if (argc <= 1) { + return result; + } + + const auto add_short = [&](std::string _key, std::string _val, + std::string_view _raw) -> rfl::Result { + result.short_order.emplace_back(_key); + if (!result.short_args.emplace(std::move(_key), std::move(_val)).second) { + return error("Duplicate short argument: " + std::string(_raw)); + } + return true; + }; + + const auto args = std::span(argv + 1, argc - 1); + bool force_positional = false; + for (size_t i = 0; i < args.size(); ++i) { + const std::string_view arg_raw(args[i]); + + if (force_positional) { + result.positional.emplace_back(arg_raw); + continue; + } + + // "--" separator: everything after is positional. + if (arg_raw == "--") { + force_positional = true; + continue; + } + + // Long argument: --key=value or --flag. + if (arg_raw.starts_with("--")) { + const auto arg = arg_raw.substr(2); + const auto eq = arg.find('='); + auto key = std::string( + eq == std::string_view::npos ? arg : arg.substr(0, eq)); + auto val = std::string( + eq == std::string_view::npos ? "" : arg.substr(eq + 1)); + if (key.empty()) { + return error("Empty key in argument: " + std::string(arg_raw)); + } + if (!result.named.emplace(std::move(key), std::move(val)).second) { + return error("Duplicate argument: " + std::string(arg_raw)); + } + continue; + } + + // Short argument: -x value, -x=value, or -x (bool). + if (arg_raw.size() >= 2 && arg_raw[0] == '-') { + const auto arg = arg_raw.substr(1); + const auto eq = arg.find('='); + + // -x=value + if (eq != std::string_view::npos) { + const auto r = add_short( + std::string(arg.substr(0, eq)), + std::string(arg.substr(eq + 1)), arg_raw); + if (!r) { return error(r.error().what()); } + continue; + } + + // -x value or -x (bool flag). + // Peek at next token: consume as value if it doesn't look like an option. + auto val = std::string(); + if (i + 1 < args.size() && !looks_like_option(args[i + 1])) { + val = std::string(args[++i]); + } + const auto r = add_short(std::string(arg), std::move(val), arg_raw); + if (!r) { return error(r.error().what()); } + continue; + } + + // Bare argument → positional. + result.positional.emplace_back(arg_raw); + } + return result; +} + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/cli/read.hpp b/include/rfl/cli/read.hpp new file mode 100644 index 00000000..e9211b96 --- /dev/null +++ b/include/rfl/cli/read.hpp @@ -0,0 +1,30 @@ +#ifndef RFL_CLI_READ_HPP_ +#define RFL_CLI_READ_HPP_ + +#include "../Processors.hpp" +#include "../SnakeCaseToKebabCase.hpp" +#include "Parser.hpp" +#include "Reader.hpp" +#include "parse_argv.hpp" +#include "resolve_args.hpp" + +namespace rfl::cli { + +/// Parses command-line arguments into a struct using reflection. +/// Field names are automatically converted from snake_case to kebab-case. +/// Example: struct field `host_name` matches CLI argument `--host-name`. +template +rfl::Result read(int argc, char* argv[]) { + using ProcessorsType = Processors; + return parse_argv(argc, argv) + .and_then(resolve_args) + .and_then([](auto _args) -> rfl::Result { + const auto r = Reader(); + const auto var = CliVarType{&_args, "", std::nullopt}; + return Parser::read(r, var); + }); +} + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/cli/resolve_args.hpp b/include/rfl/cli/resolve_args.hpp new file mode 100644 index 00000000..253d9d4c --- /dev/null +++ b/include/rfl/cli/resolve_args.hpp @@ -0,0 +1,247 @@ +#ifndef RFL_CLI_RESOLVE_ARGS_HPP_ +#define RFL_CLI_RESOLVE_ARGS_HPP_ + +#include +#include +#include +#include + +#include "../Result.hpp" +#include "../internal/is_positional.hpp" +#include "../internal/is_short.hpp" +#include "../internal/processed_t.hpp" + +namespace rfl::cli { + +struct ParsedArgs { + std::map named; + std::map short_args; + std::vector positional; + /// Insertion order of short arg keys (for stable reclaim ordering). + std::vector short_order; +}; + +namespace detail { + +template +consteval bool check_no_nested_wrappers() { + if constexpr (_is_positional) { + return !rfl::internal::is_short_v; + } + else if constexpr (_is_short) { + return !rfl::internal::is_positional_v; + } + else { + return true; + } +} + +template +struct field_info { + using FieldType = rfl::tuple_element_t<_i, typename NamedTupleType::Fields>; + using InnerType = typename FieldType::Type; + + static constexpr bool is_positional = + rfl::internal::is_positional_v; + static constexpr bool is_short = rfl::internal::is_short_v; + + static_assert( + check_no_nested_wrappers(), + "Nested wrappers (Positional> or Short<..., Positional<...>>) " + "are not allowed."); + + static constexpr std::string_view name() { return FieldType::name(); } +}; + +template +constexpr auto get_short_name() { + using Info = field_info; + if constexpr (Info::is_short) { + return Info::InnerType::short_name_; + } + else { + return rfl::internal::StringLiteral<1>(""); + } +} + +template +void collect_positional_names( + std::vector& _names, + std::index_sequence<_i, _is...> +) { + using Info = field_info; + if constexpr (Info::is_positional) { + _names.emplace_back(Info::name()); + } + if constexpr (sizeof...(_is) > 0) { + collect_positional_names(_names, std::index_sequence<_is...>{}); + } +} + +template +void collect_positional_names( + std::vector&, + std::index_sequence<> +) {} + +template +rfl::Result collect_short_mapping( + std::map& _mapping, + std::index_sequence<_i, _is...> +) { + using Info = field_info; + if constexpr (Info::is_short) { + constexpr auto short_lit = get_short_name(); + const auto short_str = std::string(short_lit.string_view()); + const auto long_str = std::string(Info::name()); + if (!_mapping.emplace(short_str, long_str).second) { + return rfl::error( + "Duplicate short name '-" + short_str + "' in struct definition."); + } + } + if constexpr (sizeof...(_is) > 0) { + return collect_short_mapping( + _mapping, std::index_sequence<_is...>{}); + } + return true; +} + +template +rfl::Result collect_short_mapping( + std::map&, + std::index_sequence<> +) { + return true; +} + +template +consteval bool is_short_bool() { + using Info = field_info; + if constexpr (Info::is_short) { + return std::is_same_v; + } + else { + return false; + } +} + +template +void collect_short_bool_names( + std::set& _names, + std::index_sequence<_i, _is...> +) { + if constexpr (is_short_bool()) { + constexpr auto short_lit = get_short_name(); + _names.emplace(short_lit.string_view()); + } + if constexpr (sizeof...(_is) > 0) { + collect_short_bool_names( + _names, std::index_sequence<_is...>{}); + } +} + +template +void collect_short_bool_names( + std::set&, + std::index_sequence<> +) {} + +} // namespace detail + +/// Resolves ParsedArgs into a flat key-value map using compile-time +/// metadata from the target struct T. +template +rfl::Result> resolve_args( + ParsedArgs _parsed +) { + using NT = rfl::internal::processed_t; + constexpr auto sz = NT::size(); + using Indices = std::make_index_sequence; + + // Collect positional field names (in declaration order). + std::vector positional_names; + detail::collect_positional_names(positional_names, Indices{}); + + // Collect short-to-long mapping. + std::map short_to_long; + const auto short_result = + detail::collect_short_mapping(short_to_long, Indices{}); + if (!short_result) { + return rfl::error(short_result.error().what()); + } + + // Reclaim non-boolean values from short bool flags. + // parse_argv doesn't know types, so `-v somefile` consumes "somefile" as + // the value of -v. Since -v is bool, "somefile" is not a valid bool value + // and must be returned to the positional list. + // We iterate in insertion order (short_order) to preserve argv ordering. + std::set short_bool_names; + detail::collect_short_bool_names(short_bool_names, Indices{}); + std::vector reclaimed; + for (const auto& short_name : _parsed.short_order) { + if (!short_bool_names.count(short_name)) { + continue; + } + auto it = _parsed.short_args.find(short_name); + if (it == _parsed.short_args.end()) { + continue; + } + auto& value = it->second; + if (value != "true" && value != "false" + && value != "0" && value != "1" + && !value.empty()) { + reclaimed.push_back(value); + value.clear(); + } + } + // Insert reclaimed values before existing positional args to preserve + // the original argv order: `-v file1 file2` → positional ["file1", "file2"]. + if (!reclaimed.empty()) { + _parsed.positional.insert( + _parsed.positional.begin(), + std::make_move_iterator(reclaimed.begin()), + std::make_move_iterator(reclaimed.end())); + } + + auto& result = _parsed.named; + + // Merge positional args. + if (_parsed.positional.size() > positional_names.size()) { + auto msg = std::string("Too many positional arguments: expected at most "); + msg += std::to_string(positional_names.size()); + msg += ", got "; + msg += std::to_string(_parsed.positional.size()); + msg += "."; + return rfl::error(std::move(msg)); + } + for (size_t i = 0; i < _parsed.positional.size(); ++i) { + const auto& long_name = positional_names[i]; + if (result.count(long_name)) { + return rfl::error( + "Conflict: positional argument and '--" + long_name + + "' both provided."); + } + result.emplace(long_name, std::move(_parsed.positional[i])); + } + + // Merge short args. + for (auto& [short_name, value] : _parsed.short_args) { + const auto it = short_to_long.find(short_name); + if (it == short_to_long.end()) { + return rfl::error("Unknown short argument: -" + short_name); + } + const auto& long_name = it->second; + if (result.count(long_name)) { + return rfl::error( + "Conflict: '-" + short_name + "' and '--" + long_name + + "' both provided."); + } + result.emplace(long_name, std::move(value)); + } + + return std::move(result); +} + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/internal/is_positional.hpp b/include/rfl/internal/is_positional.hpp new file mode 100644 index 00000000..49de639b --- /dev/null +++ b/include/rfl/internal/is_positional.hpp @@ -0,0 +1,27 @@ +#ifndef RFL_INTERNAL_ISPOSITIONAL_HPP_ +#define RFL_INTERNAL_ISPOSITIONAL_HPP_ + +#include + +#include "../Positional.hpp" + +namespace rfl { +namespace internal { + +template +class is_positional; + +template +class is_positional : public std::false_type {}; + +template +class is_positional> : public std::true_type {}; + +template +constexpr bool is_positional_v = + is_positional>>::value; + +} // namespace internal +} // namespace rfl + +#endif diff --git a/include/rfl/internal/is_short.hpp b/include/rfl/internal/is_short.hpp new file mode 100644 index 00000000..66f20087 --- /dev/null +++ b/include/rfl/internal/is_short.hpp @@ -0,0 +1,28 @@ +#ifndef RFL_INTERNAL_ISSHORT_HPP_ +#define RFL_INTERNAL_ISSHORT_HPP_ + +#include + +#include "../Short.hpp" +#include "StringLiteral.hpp" + +namespace rfl { +namespace internal { + +template +class is_short; + +template +class is_short : public std::false_type {}; + +template +class is_short> : public std::true_type {}; + +template +constexpr bool is_short_v = + is_short>>::value; + +} // namespace internal +} // namespace rfl + +#endif diff --git a/include/rfl/internal/transform_case.hpp b/include/rfl/internal/transform_case.hpp index fc10a4d3..cce9b1f3 100644 --- a/include/rfl/internal/transform_case.hpp +++ b/include/rfl/internal/transform_case.hpp @@ -72,6 +72,24 @@ consteval auto transform_camel_case() { return transform_camel_case<_name, _i + 1, chars..., _name.arr_[_i]>(); } } + +/// Transforms the field name from snake_case to kebab-case. +template +consteval auto transform_snake_to_kebab() { + if constexpr (_i == _name.arr_.size()) { + return StringLiteral(chars...); + + } else if constexpr (_name.arr_[_i] == '\0') { + return transform_snake_to_kebab<_name, _name.arr_.size(), chars...>(); + + } else if constexpr (_name.arr_[_i] == '_') { + return transform_snake_to_kebab<_name, _i + 1, chars..., '-'>(); + + } else { + return transform_snake_to_kebab<_name, _i + 1, chars..., _name.arr_[_i]>(); + } +} + } // namespace rfl::internal #endif diff --git a/include/rfl/parsing/Parser.hpp b/include/rfl/parsing/Parser.hpp index 4f1cb355..8b340631 100644 --- a/include/rfl/parsing/Parser.hpp +++ b/include/rfl/parsing/Parser.hpp @@ -18,6 +18,7 @@ #include "Parser_named_tuple.hpp" #include "Parser_optional.hpp" #include "Parser_pair.hpp" +#include "Parser_positional.hpp" #include "Parser_ptr.hpp" #include "Parser_ref.hpp" #include "Parser_reference_wrapper.hpp" @@ -27,6 +28,7 @@ #include "Parser_rfl_tuple.hpp" #include "Parser_rfl_variant.hpp" #include "Parser_shared_ptr.hpp" +#include "Parser_short.hpp" #include "Parser_skip.hpp" #include "Parser_span.hpp" #include "Parser_string_view.hpp" diff --git a/include/rfl/parsing/Parser_positional.hpp b/include/rfl/parsing/Parser_positional.hpp new file mode 100644 index 00000000..ffde0c89 --- /dev/null +++ b/include/rfl/parsing/Parser_positional.hpp @@ -0,0 +1,46 @@ +#ifndef RFL_PARSING_PARSER_POSITIONAL_HPP_ +#define RFL_PARSING_PARSER_POSITIONAL_HPP_ + +#include +#include + +#include "../Positional.hpp" +#include "../Result.hpp" +#include "Parser_base.hpp" +#include "schema/Type.hpp" + +namespace rfl { +namespace parsing { + +template + requires AreReaderAndWriter> +struct Parser, ProcessorsType> { + using InputVarType = typename R::InputVarType; + + static Result> read(const R& _r, + const InputVarType& _var) noexcept { + const auto to_positional = [](auto&& _t) { + return Positional(std::move(_t)); + }; + return Parser, ProcessorsType>::read(_r, _var) + .transform(to_positional); + } + + template + static void write(const W& _w, const Positional& _positional, + const P& _parent) { + Parser, ProcessorsType>::write( + _w, _positional.value(), _parent); + } + + static schema::Type to_schema( + std::map* _definitions) { + return Parser, ProcessorsType>::to_schema( + _definitions); + } +}; + +} // namespace parsing +} // namespace rfl + +#endif diff --git a/include/rfl/parsing/Parser_short.hpp b/include/rfl/parsing/Parser_short.hpp new file mode 100644 index 00000000..ad32a5f3 --- /dev/null +++ b/include/rfl/parsing/Parser_short.hpp @@ -0,0 +1,48 @@ +#ifndef RFL_PARSING_PARSER_SHORT_HPP_ +#define RFL_PARSING_PARSER_SHORT_HPP_ + +#include +#include + +#include "../Result.hpp" +#include "../Short.hpp" +#include "../internal/StringLiteral.hpp" +#include "Parser_base.hpp" +#include "schema/Type.hpp" + +namespace rfl { +namespace parsing { + +template + requires AreReaderAndWriter> +struct Parser, ProcessorsType> { + using InputVarType = typename R::InputVarType; + + static Result> read(const R& _r, + const InputVarType& _var) noexcept { + const auto to_short = [](auto&& _t) { + return Short<_name, T>(std::move(_t)); + }; + return Parser, ProcessorsType>::read(_r, _var) + .transform(to_short); + } + + template + static void write(const W& _w, const Short<_name, T>& _short, + const P& _parent) { + Parser, ProcessorsType>::write( + _w, _short.value(), _parent); + } + + static schema::Type to_schema( + std::map* _definitions) { + return Parser, ProcessorsType>::to_schema( + _definitions); + } +}; + +} // namespace parsing +} // namespace rfl + +#endif diff --git a/include/rfl/parsing/is_required.hpp b/include/rfl/parsing/is_required.hpp index bc4cb543..c7ddb676 100644 --- a/include/rfl/parsing/is_required.hpp +++ b/include/rfl/parsing/is_required.hpp @@ -6,10 +6,14 @@ #include #include "../Generic.hpp" +#include "../Positional.hpp" #include "../Rename.hpp" +#include "../Short.hpp" #include "../internal/StringLiteral.hpp" #include "../internal/has_reflection_type_v.hpp" +#include "../internal/is_positional.hpp" #include "../internal/is_rename.hpp" +#include "../internal/is_short.hpp" #include "is_map_like.hpp" #include "is_vector_like.hpp" @@ -45,6 +49,10 @@ consteval bool is_required() { _ignore_empty_containers>(); } else if constexpr (internal::is_rename_v) { return is_required(); + } else if constexpr (internal::is_positional_v) { + return is_required(); + } else if constexpr (internal::is_short_v) { + return is_required(); } else { return !(is_never_required_v || (_ignore_empty_containers && diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bfcab240..b6d2e1e4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,7 @@ if (REFLECTCPP_JSON) add_subdirectory(generic) add_subdirectory(json) add_subdirectory(json_c_arrays_and_inheritance) + add_subdirectory(cli) endif () if (REFLECTCPP_AVRO) diff --git a/tests/cli/CMakeLists.txt b/tests/cli/CMakeLists.txt new file mode 100644 index 00000000..73bd38dd --- /dev/null +++ b/tests/cli/CMakeLists.txt @@ -0,0 +1,21 @@ +project(reflect-cpp-cli-tests) + +file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "*.cpp") + +add_executable( + reflect-cpp-cli-tests + ${SOURCES} +) +target_precompile_headers(reflect-cpp-cli-tests PRIVATE [["rfl.hpp"]] ) + +target_include_directories(reflect-cpp-cli-tests SYSTEM PRIVATE "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/include") + +target_link_libraries(reflect-cpp-cli-tests PRIVATE reflectcpp_tests_crt) + +add_custom_command(TARGET reflect-cpp-cli-tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy -t $ $ + COMMAND_EXPAND_LISTS +) + +find_package(GTest) +gtest_discover_tests(reflect-cpp-cli-tests) diff --git a/tests/cli/test_basic.cpp b/tests/cli/test_basic.cpp new file mode 100644 index 00000000..615f2d09 --- /dev/null +++ b/tests/cli/test_basic.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include + +namespace test_basic { + +struct Config { + std::string host; + int port; + double rate; + bool verbose; +}; + +TEST(cli, test_basic) { + const char* args[] = { + "program", + "--host=localhost", + "--port=8080", + "--rate=1.5", + "--verbose" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().host, "localhost"); + EXPECT_EQ(result.value().port, 8080); + EXPECT_DOUBLE_EQ(result.value().rate, 1.5); + EXPECT_TRUE(result.value().verbose); +} + +TEST(cli, test_basic_bool_explicit_true) { + const char* args[] = {"program", "--host=x", "--port=1", "--rate=0", "--verbose=true"}; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose); +} + +TEST(cli, test_basic_bool_false) { + const char* args[] = {"program", "--host=x", "--port=1", "--rate=0", "--verbose=false"}; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_FALSE(result.value().verbose); +} + +} // namespace test_basic diff --git a/tests/cli/test_bool_short_positional.cpp b/tests/cli/test_bool_short_positional.cpp new file mode 100644 index 00000000..55a84aee --- /dev/null +++ b/tests/cli/test_bool_short_positional.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include + +namespace test_bool_short_positional { + +struct Config { + rfl::Short<"v", bool> verbose; + rfl::Positional input_file; +}; + +TEST(cli, test_bool_short_does_not_consume_positional) { + const char* args[] = { + "program", + "-v", "somefile.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().input_file(), "somefile.txt"); +} + +TEST(cli, test_bool_short_explicit_true) { + const char* args[] = { + "program", + "-v=true", "somefile.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().input_file(), "somefile.txt"); +} + +TEST(cli, test_bool_short_explicit_false) { + const char* args[] = { + "program", + "-v=false", "somefile.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_FALSE(result.value().verbose()); + EXPECT_EQ(result.value().input_file(), "somefile.txt"); +} + +struct PortConfig { + rfl::Short<"p", int> port; + rfl::Positional input_file; +}; + +TEST(cli, test_int_short_still_consumes_value) { + const char* args[] = { + "program", + "-p", "8080", "file.txt" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 8080); + EXPECT_EQ(result.value().input_file(), "file.txt"); +} + +struct MultiBoolConfig { + rfl::Short<"v", bool> verbose; + rfl::Short<"d", bool> debug; + rfl::Positional first; + rfl::Positional second; +}; + +TEST(cli, test_multi_bool_short_preserves_positional_order) { + const char* args[] = { + "program", + "-v", "file1.txt", + "-d", "file2.txt" + }; + const auto result = rfl::cli::read( + 5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose()); + EXPECT_TRUE(result.value().debug()); + EXPECT_EQ(result.value().first(), "file1.txt"); + EXPECT_EQ(result.value().second(), "file2.txt"); +} + +} // namespace test_bool_short_positional diff --git a/tests/cli/test_enum.cpp b/tests/cli/test_enum.cpp new file mode 100644 index 00000000..29c6f033 --- /dev/null +++ b/tests/cli/test_enum.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include + +namespace test_enum { + +enum class Mode { debug, release, test }; + +struct Config { + std::string name; + Mode mode; +}; + +TEST(cli, test_enum) { + const char* args[] = { + "program", + "--name=app", + "--mode=release" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "app"); + EXPECT_EQ(result.value().mode, Mode::release); +} + +} // namespace test_enum diff --git a/tests/cli/test_errors.cpp b/tests/cli/test_errors.cpp new file mode 100644 index 00000000..0570191c --- /dev/null +++ b/tests/cli/test_errors.cpp @@ -0,0 +1,31 @@ +#include +#include +#include +#include + +namespace test_errors { + +struct Config { + std::string name; + int port; +}; + +TEST(cli, test_error_invalid_arg_format) { + const char* args[] = {"program", "not-a-flag"}; + const auto result = rfl::cli::read(2, const_cast(args)); + EXPECT_FALSE(result); +} + +TEST(cli, test_error_invalid_int) { + const char* args[] = {"program", "--name=test", "--port=abc"}; + const auto result = rfl::cli::read(3, const_cast(args)); + EXPECT_FALSE(result); +} + +TEST(cli, test_error_duplicate_key) { + const char* args[] = {"program", "--name=a", "--name=b", "--port=1"}; + const auto result = rfl::cli::read(4, const_cast(args)); + EXPECT_FALSE(result); +} + +} // namespace test_errors diff --git a/tests/cli/test_flatten.cpp b/tests/cli/test_flatten.cpp new file mode 100644 index 00000000..53f763a0 --- /dev/null +++ b/tests/cli/test_flatten.cpp @@ -0,0 +1,32 @@ +#include +#include +#include +#include + +namespace test_flatten { + +struct Address { + std::string city; + int zip; +}; + +struct Person { + std::string name; + rfl::Flatten
address; +}; + +TEST(cli, test_flatten) { + const char* args[] = { + "program", + "--name=Alice", + "--city=Springfield", + "--zip=12345" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "Alice"); + EXPECT_EQ(result.value().address().city, "Springfield"); + EXPECT_EQ(result.value().address().zip, 12345); +} + +} // namespace test_flatten diff --git a/tests/cli/test_kebab_case.cpp b/tests/cli/test_kebab_case.cpp new file mode 100644 index 00000000..33a18a06 --- /dev/null +++ b/tests/cli/test_kebab_case.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +namespace test_kebab_case { + +struct Config { + std::string host_name; + int max_retries; + bool dry_run; +}; + +TEST(cli, test_kebab_case_conversion) { + const char* args[] = { + "program", + "--host-name=server1", + "--max-retries=3", + "--dry-run" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().host_name, "server1"); + EXPECT_EQ(result.value().max_retries, 3); + EXPECT_TRUE(result.value().dry_run); +} + +} // namespace test_kebab_case diff --git a/tests/cli/test_nested.cpp b/tests/cli/test_nested.cpp new file mode 100644 index 00000000..a09f80e4 --- /dev/null +++ b/tests/cli/test_nested.cpp @@ -0,0 +1,32 @@ +#include +#include +#include +#include + +namespace test_nested { + +struct Database { + std::string host; + int port; +}; + +struct Server { + std::string name; + Database database; +}; + +TEST(cli, test_nested) { + const char* args[] = { + "program", + "--name=myserver", + "--database.host=db.local", + "--database.port=5432" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "myserver"); + EXPECT_EQ(result.value().database.host, "db.local"); + EXPECT_EQ(result.value().database.port, 5432); +} + +} // namespace test_nested diff --git a/tests/cli/test_optional.cpp b/tests/cli/test_optional.cpp new file mode 100644 index 00000000..1a5e1217 --- /dev/null +++ b/tests/cli/test_optional.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include +#include + +namespace test_optional { + +struct Config { + std::string name; + std::optional port; + std::optional label; +}; + +TEST(cli, test_optional_present) { + const char* args[] = { + "program", + "--name=test", + "--port=3000", + "--label=dev" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "test"); + ASSERT_TRUE(result.value().port.has_value()); + EXPECT_EQ(*result.value().port, 3000); + ASSERT_TRUE(result.value().label.has_value()); + EXPECT_EQ(*result.value().label, "dev"); +} + +TEST(cli, test_optional_absent) { + const char* args[] = { + "program", + "--name=test" + }; + const auto result = rfl::cli::read(2, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "test"); + EXPECT_FALSE(result.value().port.has_value()); + EXPECT_FALSE(result.value().label.has_value()); +} + +} // namespace test_optional diff --git a/tests/cli/test_positional.cpp b/tests/cli/test_positional.cpp new file mode 100644 index 00000000..da1d4172 --- /dev/null +++ b/tests/cli/test_positional.cpp @@ -0,0 +1,98 @@ +#include +#include +#include +#include + +namespace test_positional { + +struct Config { + rfl::Positional input_file; + rfl::Positional output_file; + bool verbose; +}; + +TEST(cli, test_positional_basic) { + const char* args[] = { + "program", + "in.txt", + "out.txt", + "--verbose" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "in.txt"); + EXPECT_EQ(result.value().output_file(), "out.txt"); + EXPECT_TRUE(result.value().verbose); +} + +TEST(cli, test_positional_as_named) { + const char* args[] = { + "program", + "--input-file=in.txt", + "--output-file=out.txt", + "--verbose" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "in.txt"); + EXPECT_EQ(result.value().output_file(), "out.txt"); +} + +TEST(cli, test_positional_too_many) { + const char* args[] = { + "program", + "in.txt", + "out.txt", + "extra.txt" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_FALSE(result); +} + +struct SimpleConfig { + rfl::Positional input_file; + rfl::Positional output_file; +}; + +TEST(cli, test_positional_double_dash_separator) { + const char* args[] = { + "program", + "--", + "--not-a-flag", + "out.txt" + }; + const auto result = + rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "--not-a-flag"); + EXPECT_EQ(result.value().output_file(), "out.txt"); +} + +TEST(cli, test_positional_conflict_with_named) { + const char* args[] = { + "program", + "in.txt", + "--input-file=other.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_FALSE(result); +} + +struct OptionalPositional { + rfl::Positional required_file; + rfl::Positional> optional_file; +}; + +TEST(cli, test_positional_optional_missing) { + const char* args[] = { + "program", + "in.txt" + }; + const auto result = + rfl::cli::read(2, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().required_file(), "in.txt"); + EXPECT_FALSE(result.value().optional_file().has_value()); +} + +} // namespace test_positional diff --git a/tests/cli/test_positional_and_short.cpp b/tests/cli/test_positional_and_short.cpp new file mode 100644 index 00000000..2b5caea0 --- /dev/null +++ b/tests/cli/test_positional_and_short.cpp @@ -0,0 +1,47 @@ +#include +#include +#include +#include + +namespace test_positional_and_short { + +struct Config { + rfl::Positional input_file; + rfl::Short<"o", std::string> output_dir; + rfl::Short<"v", bool> verbose; + int count; +}; + +TEST(cli, test_positional_and_short_combined) { + const char* args[] = { + "program", + "data.csv", + "-o", "/tmp/out", + "-v", + "--count=10" + }; + const auto result = rfl::cli::read(6, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "data.csv"); + EXPECT_EQ(result.value().output_dir(), "/tmp/out"); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().count, 10); +} + +TEST(cli, test_positional_and_short_all_long) { + const char* args[] = { + "program", + "--input-file=data.csv", + "--output-dir=/tmp/out", + "--verbose", + "--count=10" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "data.csv"); + EXPECT_EQ(result.value().output_dir(), "/tmp/out"); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().count, 10); +} + +} // namespace test_positional_and_short diff --git a/tests/cli/test_rename.cpp b/tests/cli/test_rename.cpp new file mode 100644 index 00000000..39fe709e --- /dev/null +++ b/tests/cli/test_rename.cpp @@ -0,0 +1,25 @@ +#include +#include +#include +#include + +namespace test_rename { + +struct Config { + rfl::Rename<"output-dir", std::string> output_dir; + int count; +}; + +TEST(cli, test_rename) { + const char* args[] = { + "program", + "--output-dir=/tmp/out", + "--count=5" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().output_dir(), "/tmp/out"); + EXPECT_EQ(result.value().count, 5); +} + +} // namespace test_rename diff --git a/tests/cli/test_short.cpp b/tests/cli/test_short.cpp new file mode 100644 index 00000000..e018c72d --- /dev/null +++ b/tests/cli/test_short.cpp @@ -0,0 +1,106 @@ +#include +#include +#include +#include + +namespace test_short { + +struct Config { + std::string host; + rfl::Short<"p", int> port; + rfl::Short<"v", bool> verbose; +}; + +TEST(cli, test_short_basic) { + const char* args[] = { + "program", + "--host=localhost", + "-p", "8080", + "-v" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().host, "localhost"); + EXPECT_EQ(result.value().port(), 8080); + EXPECT_TRUE(result.value().verbose()); +} + +TEST(cli, test_short_equals_syntax) { + const char* args[] = { + "program", + "--host=localhost", + "-p=8080", + "-v" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 8080); +} + +TEST(cli, test_short_as_long) { + const char* args[] = { + "program", + "--host=localhost", + "--port=8080", + "--verbose" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 8080); + EXPECT_TRUE(result.value().verbose()); +} + +TEST(cli, test_short_unknown) { + const char* args[] = { + "program", + "--host=localhost", + "-x", "foo" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_FALSE(result); +} + +TEST(cli, test_short_conflict_with_long) { + const char* args[] = { + "program", + "--host=localhost", + "--port=8080", + "-p", "9090" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_FALSE(result); +} + +struct NegativeConfig { + std::string host; + rfl::Short<"p", int> port; +}; + +TEST(cli, test_short_negative_value) { + const char* args[] = { + "program", + "--host=localhost", + "-p", "-42" + }; + const auto result = rfl::cli::read( + 4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), -42); +} + +struct FloatConfig { + rfl::Short<"t", double> threshold; +}; + +TEST(cli, test_short_negative_float) { + const char* args[] = { + "program", + "-t", "-3.14" + }; + const auto result = rfl::cli::read( + 3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_DOUBLE_EQ(result.value().threshold(), -3.14); +} + +} // namespace test_short diff --git a/tests/cli/test_vector.cpp b/tests/cli/test_vector.cpp new file mode 100644 index 00000000..cd672fbf --- /dev/null +++ b/tests/cli/test_vector.cpp @@ -0,0 +1,59 @@ +#include +#include +#include +#include +#include + +namespace test_vector { + +struct Config { + std::string name; + std::vector tags; +}; + +TEST(cli, test_vector) { + const char* args[] = { + "program", + "--name=app", + "--tags=dev,prod,staging" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "app"); + ASSERT_EQ(result.value().tags.size(), 3u); + EXPECT_EQ(result.value().tags[0], "dev"); + EXPECT_EQ(result.value().tags[1], "prod"); + EXPECT_EQ(result.value().tags[2], "staging"); +} + +struct IntConfig { + std::vector values; +}; + +TEST(cli, test_vector_int) { + const char* args[] = { + "program", + "--values=1,2,3" + }; + const auto result = rfl::cli::read(2, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + ASSERT_EQ(result.value().values.size(), 3u); + EXPECT_EQ(result.value().values[0], 1); + EXPECT_EQ(result.value().values[1], 2); + EXPECT_EQ(result.value().values[2], 3); +} + +TEST(cli, test_vector_skips_empty_elements) { + const char* args[] = { + "program", + "--name=app", + "--tags=dev,,prod" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + ASSERT_EQ(result.value().tags.size(), 2u); + EXPECT_EQ(result.value().tags[0], "dev"); + EXPECT_EQ(result.value().tags[1], "prod"); +} + +} // namespace test_vector diff --git a/tests/json/test_positional_and_short.cpp b/tests/json/test_positional_and_short.cpp new file mode 100644 index 00000000..0dc3e84c --- /dev/null +++ b/tests/json/test_positional_and_short.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include + +#include "write_and_read.hpp" + +namespace test_positional_and_short_json { + +struct PositionalConfig { + rfl::Positional input_file; + rfl::Positional count; +}; + +TEST(json, test_positional_write_and_read) { + write_and_read( + PositionalConfig{"data.csv", 42}, + R"({"input_file":"data.csv","count":42})"); +} + +struct ShortConfig { + rfl::Short<"p", int> port; + rfl::Short<"v", bool> verbose; + std::string host; +}; + +TEST(json, test_short_write_and_read) { + write_and_read( + ShortConfig{8080, true, "localhost"}, + R"({"port":8080,"verbose":true,"host":"localhost"})"); +} + +struct CombinedConfig { + rfl::Positional input_file; + rfl::Short<"o", std::string> output_dir; + rfl::Short<"v", bool> verbose; + int count; + std::vector tags; +}; + +TEST(json, test_positional_and_short_combined_write_and_read) { + write_and_read( + CombinedConfig{"data.csv", "/tmp/out", true, 10, {"dev", "prod"}}, + R"({"input_file":"data.csv","output_dir":"/tmp/out","verbose":true,"count":10,"tags":["dev","prod"]})"); +} + +TEST(json, test_positional_read_from_json) { + const auto result = rfl::json::read( + R"({"input_file":"test.txt","count":7})"); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "test.txt"); + EXPECT_EQ(result.value().count(), 7); +} + +TEST(json, test_short_read_from_json) { + const auto result = rfl::json::read( + R"({"port":3000,"verbose":false,"host":"example.com"})"); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 3000); + EXPECT_FALSE(result.value().verbose()); + EXPECT_EQ(result.value().host, "example.com"); +} + +} // namespace test_positional_and_short_json