This is an automated email from the ASF dual-hosted git repository.
dmeden pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 9098506a55 ArgParser: Add mutually exclusive option groups (#12621)
9098506a55 is described below
commit 9098506a55a3ef6f96009aac2d855096b715a3f8
Author: Damian Meden <[email protected]>
AuthorDate: Tue Nov 11 10:16:18 2025 +0100
ArgParser: Add mutually exclusive option groups (#12621)
* ArgParser: Add mutually exclusive option groups
Implement support for mutually exclusive option groups in ArgParser,
allowing options that cannot be used together to be properly validated
and documented.
This feature is useful for conflicting options like --verbose/--quiet
or --enable/--disable, where only one option from a group should be
specified at a time.
Unit tests coverage.
---
.../internal-libraries/ArgParser.en.rst | 49 +++++
include/tscore/ArgParser.h | 60 +++++-
src/tscore/ArgParser.cc | 230 ++++++++++++++++++---
src/tscore/CMakeLists.txt | 1 +
src/tscore/unit_tests/test_ArgParser_MutexGroup.cc | 215 +++++++++++++++++++
5 files changed, 530 insertions(+), 25 deletions(-)
diff --git a/doc/developer-guide/internal-libraries/ArgParser.en.rst
b/doc/developer-guide/internal-libraries/ArgParser.en.rst
index 235775bc9b..9336f8b843 100644
--- a/doc/developer-guide/internal-libraries/ArgParser.en.rst
+++ b/doc/developer-guide/internal-libraries/ArgParser.en.rst
@@ -122,6 +122,38 @@ which is equivalent to
In this case, `subinit` is the subcommand of `init` and `--initoption` is a
switch of command `remove`.
+Mutually Exclusive Groups
+--------------------------
+
+ArgParser supports mutually exclusive option groups, where only one option
from the group can be used at a time.
+This is useful for options that conflict with each other, such as
``--verbose`` and ``--quiet``, or ``--enable`` and ``--disable``.
+
+To create a mutually exclusive group:
+
+.. code-block:: cpp
+
+ // Create a mutex group (optional by default)
+ parser.add_mutex_group("verbosity", false, "Verbosity level");
+
+ // Add options to the group
+ parser.add_option_to_group("verbosity", "--verbose", "-v", "Enable verbose
output");
+ parser.add_option_to_group("verbosity", "--quiet", "-q", "Suppress output");
+
+The second parameter to ``add_mutex_group()`` specifies whether the group is
required (``true``) or optional (``false``).
+If required, the user must specify exactly one option from the group. If
optional, the user may specify zero or one option.
+
+When multiple options from the same group are used together, ArgParser will
display an error message and exit.
+
+Example with a required group:
+
+.. code-block:: cpp
+
+ // Create a required mutex group for output format
+ parser.add_mutex_group("format", true, "Output format (required)");
+ parser.add_option_to_group("format", "--json", "-j", "JSON format");
+ parser.add_option_to_group("format", "--xml", "-x", "XML format");
+
+ // User must specify either --json or --xml, but not both
Parsing Arguments
-----------------
@@ -203,6 +235,15 @@ Classes
Return the error message of the parser.
+ .. function:: void add_mutex_group(std::string const &group_name, bool
required, std::string const &description)
+
+ Create a mutually exclusive option group. Only one option from the group
can be used at a time.
+ If *required* is true, exactly one option from the group must be
specified.
+
+ .. function:: void add_option_to_group(std::string const &group_name,
std::string const &long_option, std::string const &short_option, std::string
const &description)
+
+ Add an option to a mutually exclusive group. The option will be created
and associated with the specified group.
+
.. class:: Option
:class:`Option` is a data struct containing information about an option.
@@ -235,6 +276,14 @@ Classes
set the current command as default
+ .. function:: void add_mutex_group(std::string const &group_name, bool
required, std::string const &description)
+
+ Create a mutually exclusive option group for this command.
+
+ .. function:: void add_option_to_group(std::string const &group_name,
std::string const &long_option, std::string const &short_option, std::string
const &description)
+
+ Add an option to a mutually exclusive group for this command.
+
.. class:: Arguments
:class:`Arguments` holds the parsed arguments and function to invoke.
diff --git a/include/tscore/ArgParser.h b/include/tscore/ArgParser.h
index 4af3988741..49e27fb8a2 100644
--- a/include/tscore/ArgParser.h
+++ b/include/tscore/ArgParser.h
@@ -133,6 +133,17 @@ public:
std::string key; // look-up key
};
+ // Mutually exclusive group structure
+ // Options in the same group cannot be used together
+ struct MutexGroup {
+ std::string name; // group identifier
+ std::vector<std::string> options; // list of long option names in
this group
+ bool required{false}; // if true, one option from
group must be specified
+ std::string description; // optional description for help
message
+
+ MutexGroup(std::string const &n, bool req = false, std::string const &desc
= "") : name(n), required(req), description(desc) {}
+ };
+
// Class for commands in a nested way
class Command
{
@@ -145,12 +156,26 @@ public:
Command &operator=(Command &&) = default;
~Command();
/** Add an option to current command
- @return The Option object.
+ @return The Command object for chaining.
*/
Command &add_option(std::string const &long_option, std::string const
&short_option, std::string const &description,
std::string const &envvar = "", unsigned arg_num = 0,
std::string const &default_value = "",
std::string const &key = "");
+ /** Create a mutually exclusive group of options
+ @param group_name Identifier for the group
+ @return The Command object for chaining.
+ */
+ Command &add_mutex_group(std::string const &group_name, bool required =
false, std::string const &description = "");
+
+ /** Add an option to a mutually exclusive group
+ @param group_name The group to add the option to
+ @return The Command object for chaining.
+ */
+ Command &add_option_to_group(std::string const &group_name, std::string
const &long_option, std::string const &short_option,
+ std::string const &description, std::string
const &envvar = "", unsigned arg_num = 0,
+ std::string const &default_value = "",
std::string const &key = "");
+
/** Two ways of adding a sub-command to current command:
@return The new sub-command instance.
*/
@@ -190,6 +215,8 @@ public:
void version_message() const;
// Helper method for parse()
void append_option_data(Arguments &ret, AP_StrVec &args, int index);
+ // Helper method to validate mutually exclusive groups
+ void validate_mutex_groups(Arguments &ret) const;
// The command name and help message
std::string _name;
std::string _description;
@@ -214,6 +241,13 @@ public:
// Map for fast searching: <short option: long option>
std::map<std::string, std::string> _option_map;
+ // Mutually exclusive groups
+ // Key: group name. Value: MutexGroup object
+ std::map<std::string, MutexGroup> _mutex_groups;
+ // Map option to its mutex group (for fast lookup during validation)
+ // Key: long option name. Value: group name
+ std::map<std::string, std::string> _option_to_group;
+
// require command / option for this parser
bool _command_required = false;
@@ -226,12 +260,26 @@ public:
~ArgParser();
/** Add an option to current command with arguments
- @return The Option object.
+ @return The Command object for chaining.
*/
Command &add_option(std::string const &long_option, std::string const
&short_option, std::string const &description,
std::string const &envvar = "", unsigned arg_num = 0,
std::string const &default_value = "",
std::string const &key = "");
+ /** Create a mutually exclusive group of options
+ @param group_name Identifier for the group
+ @return The Command object for chaining.
+ */
+ Command &add_mutex_group(std::string const &group_name, bool required =
false, std::string const &description = "");
+
+ /** Add an option to a mutually exclusive group
+ @param group_name The group to add the option to
+ @return The Command object for chaining.
+ */
+ Command &add_option_to_group(std::string const &group_name, std::string
const &long_option, std::string const &short_option,
+ std::string const &description, std::string
const &envvar = "", unsigned arg_num = 0,
+ std::string const &default_value = "",
std::string const &key = "");
+
/** Two ways of adding command to the parser:
@return The new command instance.
*/
@@ -262,6 +310,14 @@ public:
void add_description(std::string const &descr);
protected:
+ // Exit handler - throws in test mode, calls exit() otherwise
+ static void do_exit(int code);
+ // Enable test mode - makes do_exit() throw instead of calling exit() for
unit testing
+ static void set_test_mode(bool test = true);
+
+ // When true, do_exit() throws instead of calling exit()
+ static bool _test_mode;
+
// Converted from 'const char **argv' for the use of parsing and help
AP_StrVec _argv;
// the top level command object for program use
diff --git a/src/tscore/ArgParser.cc b/src/tscore/ArgParser.cc
index c7b474c889..8cc91fa524 100644
--- a/src/tscore/ArgParser.cc
+++ b/src/tscore/ArgParser.cc
@@ -42,6 +42,8 @@ int usage_return_code = EX_USAGE;
namespace ts
{
+bool ArgParser::_test_mode = false;
+
ArgParser::ArgParser() {}
ArgParser::ArgParser(std::string const &name, std::string const &description,
std::string const &envvar, unsigned arg_num,
@@ -61,6 +63,23 @@ ArgParser::add_option(std::string const &long_option,
std::string const &short_o
return _top_level_command.add_option(long_option, short_option, description,
envvar, arg_num, default_value, key);
}
+// Create a mutually exclusive group
+ArgParser::Command &
+ArgParser::add_mutex_group(std::string const &group_name, bool required,
std::string const &description)
+{
+ return _top_level_command.add_mutex_group(group_name, required, description);
+}
+
+// Add an option to a mutually exclusive group
+ArgParser::Command &
+ArgParser::add_option_to_group(std::string const &group_name, std::string
const &long_option, std::string const &short_option,
+ std::string const &description, std::string
const &envvar, unsigned arg_num,
+ std::string const &default_value, std::string
const &key)
+{
+ return _top_level_command.add_option_to_group(group_name, long_option,
short_option, description, envvar, arg_num, default_value,
+ key);
+}
+
// add sub-command with only function
ArgParser::Command &
ArgParser::add_command(std::string const &cmd_name, std::string const
&cmd_description, Function const &f, std::string const &key)
@@ -118,7 +137,7 @@ ArgParser::Command::help_message(std::string_view err) const
std::cout << "\nExample Usage: " << _example_usage << std::endl;
}
// standard return code
- exit(usage_return_code);
+ ArgParser::do_exit(usage_return_code);
}
void
@@ -127,7 +146,7 @@ ArgParser::Command::version_message() const
// unified version message of ATS
AppVersionInfo::setup_version(_name.c_str());
AppVersionInfo::print_version();
- exit(0);
+ ArgParser::do_exit(0);
}
void
@@ -136,12 +155,12 @@ ArgParser::set_default_command(std::string const &cmd)
if (default_command.empty()) {
if (_top_level_command._subcommand_list.find(cmd) ==
_top_level_command._subcommand_list.end()) {
std::cerr << "Error: Default command " << cmd << "not found" <<
std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
default_command = cmd;
} else if (cmd != default_command) {
std::cerr << "Error: Default command " << default_command << "already
existed" << std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
}
@@ -158,7 +177,7 @@ ArgParser::parse(const char **argv)
}
if (size == 0) {
std::cout << "Error: invalid argv provided" << std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
// the name of the program only
_argv[0] = _argv[0].substr(_argv[0].find_last_of('/') + 1);
@@ -238,20 +257,20 @@ ArgParser::Command::check_option(std::string const
&long_option, std::string con
if (long_option.size() < 3 || long_option[0] != '-' || long_option[1] !=
'-') {
// invalid name
std::cerr << "Error: invalid long option added: '" + long_option + "'" <<
std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
if (short_option.size() > 2 || (short_option.size() > 0 && short_option[0]
!= '-')) {
// invalid short option
std::cerr << "Error: invalid short option added: '" + short_option + "'"
<< std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
// find if existing in option list
if (_option_list.find(long_option) != _option_list.end()) {
std::cerr << "Error: long option '" + long_option + "' already existed" <<
std::endl;
- exit(1);
+ ArgParser::do_exit(1);
} else if (_option_map.find(short_option) != _option_map.end()) {
std::cerr << "Error: short option '" + short_option + "' already existed"
<< std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
}
@@ -262,12 +281,12 @@ ArgParser::Command::check_command(std::string const
&name, std::string const & /
if (name.empty()) {
// invalid name
std::cerr << "Error: empty command cannot be added" << std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
// find if existing in subcommand list
if (_subcommand_list.find(name) != _subcommand_list.end()) {
std::cerr << "Error: command already exists: '" + name + "'" << std::endl;
- exit(1);
+ ArgParser::do_exit(1);
}
}
@@ -287,6 +306,50 @@ ArgParser::Command::add_option(std::string const
&long_option, std::string const
return *this;
}
+// Create a mutually exclusive group
+ArgParser::Command &
+ArgParser::Command::add_mutex_group(std::string const &group_name, bool
required, std::string const &description)
+{
+ if (group_name.empty()) {
+ std::cerr << "Error: Mutex group name cannot be empty" << std::endl;
+ ArgParser::do_exit(1);
+ }
+
+ if (_mutex_groups.find(group_name) != _mutex_groups.end()) {
+ std::cerr << "Error: Mutex group '" << group_name << "' already exists" <<
std::endl;
+ ArgParser::do_exit(1);
+ }
+ _mutex_groups.emplace(group_name, MutexGroup(group_name, required,
description));
+ return *this;
+}
+
+// Add an option to a mutually exclusive group
+ArgParser::Command &
+ArgParser::Command::add_option_to_group(std::string const &group_name,
std::string const &long_option,
+ std::string const &short_option,
std::string const &description, std::string const &envvar,
+ unsigned arg_num, std::string const
&default_value, std::string const &key)
+{
+ if (group_name.empty()) {
+ std::cerr << "Error: Mutex group name cannot be empty" << std::endl;
+ ArgParser::do_exit(1);
+ }
+
+ auto it_mutex_group = _mutex_groups.find(group_name);
+ if (it_mutex_group == _mutex_groups.end()) {
+ std::cerr << "Error: Mutex group '" << group_name << "' not found" <<
std::endl;
+ ArgParser::do_exit(1);
+ }
+
+ // Add the option normally
+ add_option(long_option, short_option, description, envvar, arg_num,
default_value, key);
+
+ // Track this option in the mutex group
+ it_mutex_group->second.options.push_back(long_option);
+ _option_to_group[long_option] = group_name;
+
+ return *this;
+}
+
// add sub-command with only function
ArgParser::Command &
ArgParser::Command::add_command(std::string const &cmd_name, std::string const
&cmd_description, Function const &f,
@@ -343,24 +406,36 @@ ArgParser::Command::output_command(std::ostream &out,
std::string const &prefix)
void
ArgParser::Command::output_option() const
{
+ // Helper method to build argument message
+ auto arg_msg_builder = [](unsigned num) -> std::string {
+ if (num == 1) {
+ return {" <arg>"};
+ } else if (num == MORE_THAN_ZERO_ARG_N) {
+ return {" [<arg> ...]"};
+ } else if (num == MORE_THAN_ONE_ARG_N) {
+ return {" <arg> ..."};
+ } else {
+ return " <arg1> ... <arg" + std::to_string(num) + ">";
+ }
+ };
+
+ // First, output regular options (excluding those in mutex groups)
for (const auto &it : _option_list) {
+ // Skip if this option is in a mutex group (it will be displayed in the
mutex group)
+ if (_option_to_group.find(it.first) != _option_to_group.end()) {
+ continue;
+ }
+
std::string msg;
if (!it.second.short_option.empty()) {
msg = it.second.short_option + ", ";
}
- msg += it.first;
- unsigned num = it.second.arg_num;
- if (num != 0) {
- if (num == 1) {
- msg = msg + " <arg>";
- } else if (num == MORE_THAN_ZERO_ARG_N) {
- msg = msg + " [<arg> ...]";
- } else if (num == MORE_THAN_ONE_ARG_N) {
- msg = msg + " <arg> ...";
- } else {
- msg = msg + " <arg1> ... <arg" + std::to_string(num) + ">";
- }
+
+ msg += it.first;
+ if (it.second.arg_num != 0) {
+ msg += arg_msg_builder(it.second.arg_num);
}
+
if (!it.second.default_value.empty()) {
if (INDENT_ONE - static_cast<int>(msg.size()) < 0) {
msg = msg + "\n" + std::string(INDENT_ONE, ' ') +
it.second.default_value;
@@ -376,6 +451,50 @@ ArgParser::Command::output_option() const
}
}
}
+
+ // Then output mutually exclusive groups
+ for (const auto &[group_name, group] : _mutex_groups) {
+ std::cout << "\nGroup (" << group_name;
+ if (group.required) {
+ std::cout << ", required";
+ }
+ std::cout << ")";
+ if (!group.description.empty()) {
+ std::cout << " - " << group.description;
+ }
+ std::cout << std::endl;
+
+ for (const auto &option_name : group.options) {
+ auto const it = _option_list.find(option_name);
+ if (it != _option_list.end()) {
+ std::string msg{" "}; // Indent group options
+ if (!it->second.short_option.empty()) {
+ msg += it->second.short_option + ", ";
+ }
+
+ msg += it->first;
+ if (it->second.arg_num != 0) {
+ msg += arg_msg_builder(it->second.arg_num);
+ }
+
+ if (!it->second.default_value.empty()) {
+ if (INDENT_ONE - static_cast<int>(msg.size()) < 0) {
+ msg = msg + "\n" + std::string(INDENT_ONE, ' ') +
it->second.default_value;
+ } else {
+ msg = msg + std::string(INDENT_ONE - msg.size(), ' ') +
it->second.default_value;
+ }
+ }
+
+ if (!it->second.description.empty()) {
+ if (INDENT_TWO - static_cast<int>(msg.size()) < 0) {
+ std::cout << msg << "\n" << std::string(INDENT_TWO, ' ') <<
it->second.description << std::endl;
+ } else {
+ std::cout << msg << std::string(INDENT_TWO - msg.size(), ' ') <<
it->second.description << std::endl;
+ }
+ }
+ }
+ }
+ }
}
// helper method to handle the arguments and put them nicely in arguments
@@ -410,6 +529,51 @@ handle_args(Arguments &ret, AP_StrVec &args, std::string
const &name, unsigned a
return "";
}
+// Validate mutually exclusive groups
+void
+ArgParser::Command::validate_mutex_groups(Arguments &ret) const
+{
+ // Check each mutex group
+ for (const auto &[group_name, group] : _mutex_groups) {
+ std::vector<std::string> used_options;
+
+ // Find which options from this group were used
+ for (const auto &option_name : group.options) {
+ auto it = _option_list.find(option_name);
+ if (it != _option_list.end()) {
+ // Check if this option was called
+ if (ret.get(it->second.key)) {
+ used_options.push_back(option_name);
+ }
+ }
+ }
+
+ // Validate: only one option from the group can be used
+ if (used_options.size() > 1) {
+ std::string error_msg = "Error: Options in mutex group '" + group_name +
"' are mutually exclusive. Used: ";
+ for (size_t i = 0; i < used_options.size(); ++i) {
+ if (i > 0) {
+ error_msg += ", ";
+ }
+ error_msg += used_options[i];
+ }
+ help_message(error_msg);
+ }
+
+ // Validate: if group is required, at least one option must be used
+ if (group.required && used_options.empty()) {
+ std::string error_msg = "Error: One option from required mutex group '"
+ group_name + "' must be specified. Options: ";
+ for (size_t i = 0; i < group.options.size(); ++i) {
+ if (i > 0) {
+ error_msg += ", ";
+ }
+ error_msg += group.options[i];
+ }
+ help_message(error_msg);
+ }
+ }
+}
+
// Append the args of option to parsed data. Return true if there is any
option called
void
ArgParser::Command::append_option_data(Arguments &ret, AP_StrVec &args, int
index)
@@ -527,7 +691,11 @@ ArgParser::Command::parse(Arguments &ret, AP_StrVec &args)
const char *const env = getenv(_envvar.c_str());
ret.set_env(_key, nullptr != env ? env : "");
}
+
+ // Validate mutually exclusive groups
+ validate_mutex_groups(ret);
}
+
if (command_called) {
bool flag = false;
// recursively call subcommand
@@ -682,5 +850,21 @@ ArgumentData::end() const noexcept
{
return _values.end();
}
+// protected method for testing
+/*static*/ void
+ArgParser::set_test_mode(bool test)
+{
+ _test_mode = test;
+}
+
+// protected method for testing
+/*static*/ void
+ArgParser::do_exit(int code)
+{
+ if (_test_mode) {
+ throw std::runtime_error("Test mode: exit with code " +
std::to_string(code));
+ }
+ exit(code);
+}
} // namespace ts
diff --git a/src/tscore/CMakeLists.txt b/src/tscore/CMakeLists.txt
index d5d1e2b85d..0693798b4a 100644
--- a/src/tscore/CMakeLists.txt
+++ b/src/tscore/CMakeLists.txt
@@ -176,6 +176,7 @@ if(BUILD_TESTING)
unit_tests/test_layout.cc
unit_tests/test_scoped_resource.cc
unit_tests/test_Version.cc
+ unit_tests/test_ArgParser_MutexGroup.cc
)
target_link_libraries(
test_tscore
diff --git a/src/tscore/unit_tests/test_ArgParser_MutexGroup.cc
b/src/tscore/unit_tests/test_ArgParser_MutexGroup.cc
new file mode 100644
index 0000000000..5ffbd04343
--- /dev/null
+++ b/src/tscore/unit_tests/test_ArgParser_MutexGroup.cc
@@ -0,0 +1,215 @@
+/** @file
+
+ Unit test for ArgParser mutually exclusive groups
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include <catch2/catch_test_macros.hpp>
+#include "tscore/ArgParser.h"
+
+TEST_CASE("Mutex groups - optional group", "[mutex_groups]")
+{
+ ts::ArgParser parser;
+ parser.add_description("Test optional mutex group");
+ parser.add_global_usage("test [OPTIONS]");
+
+ // Create an OPTIONAL mutex group for verbosity
+ parser.add_mutex_group("verbosity", false, "Verbosity level");
+ parser.add_option_to_group("verbosity", "--verbose", "-v", "Enable verbose
output");
+ parser.add_option_to_group("verbosity", "--quiet", "-q", "Suppress output");
+
+ // Test with --verbose
+ const char *argv1[] = {"test", "--verbose", nullptr};
+ ts::Arguments args1 = parser.parse(argv1);
+ REQUIRE(args1.get("verbose") == true);
+ REQUIRE(args1.get("quiet") == false);
+
+ // Test with --quiet
+ const char *argv2[] = {"test", "--quiet", nullptr};
+ ts::Arguments args2 = parser.parse(argv2);
+ REQUIRE(args2.get("verbose") == false);
+ REQUIRE(args2.get("quiet") == true);
+
+ // Test with no option (optional group, so this is valid)
+ const char *argv3[] = {"test", nullptr};
+ ts::Arguments args3 = parser.parse(argv3);
+ REQUIRE(args3.get("verbose") == false);
+ REQUIRE(args3.get("quiet") == false);
+
+ // Test with short options
+ const char *argv4[] = {"test", "-v", nullptr};
+ ts::Arguments args4 = parser.parse(argv4);
+ REQUIRE(args4.get("verbose") == true);
+}
+
+TEST_CASE("Mutex groups - required group", "[mutex_groups]")
+{
+ ts::ArgParser parser;
+ parser.add_description("Test required mutex group");
+ parser.add_global_usage("test [OPTIONS]");
+ // Create a REQUIRED mutex group for output format
+ parser.add_mutex_group("format", true, "Output format (required)");
+ parser.add_option_to_group("format", "--json", "-j", "Output in JSON
format");
+ parser.add_option_to_group("format", "--xml", "-x", "Output in XML format");
+ parser.add_option_to_group("format", "--yaml", "-y", "Output in YAML
format");
+
+ // Test with --json
+ const char *argv1[] = {"test", "--json", nullptr};
+ ts::Arguments args1 = parser.parse(argv1);
+ REQUIRE(args1.get("json") == true);
+ REQUIRE(args1.get("xml") == false);
+ REQUIRE(args1.get("yaml") == false);
+
+ // Test with --xml
+ const char *argv2[] = {"test", "--xml", nullptr};
+ ts::Arguments args2 = parser.parse(argv2);
+ REQUIRE(args2.get("json") == false);
+ REQUIRE(args2.get("xml") == true);
+ REQUIRE(args2.get("yaml") == false);
+
+ // Test with short option
+ const char *argv3[] = {"test", "-y", nullptr};
+ ts::Arguments args3 = parser.parse(argv3);
+ REQUIRE(args3.get("yaml") == true);
+}
+
+TEST_CASE("Mutex groups - combined with regular options", "[mutex_groups]")
+{
+ ts::ArgParser parser;
+ parser.add_description("Test mutex groups with regular options");
+ parser.add_global_usage("test [OPTIONS]");
+
+ // Mutex group
+ parser.add_mutex_group("format", false, "Output format");
+ parser.add_option_to_group("format", "--json", "-j", "Output in JSON
format");
+ parser.add_option_to_group("format", "--xml", "-x", "Output in XML format");
+
+ // Regular option
+ parser.add_option("--output", "-o", "Output file", "", 1);
+
+ // Test with both mutex group option and regular option
+ const char *argv1[] = {"test", "--json", "--output", "file.txt", nullptr};
+ ts::Arguments args1 = parser.parse(argv1);
+ REQUIRE(args1.get("json") == true);
+ REQUIRE(args1.get("output") == true);
+ REQUIRE(args1.get("output").value() == "file.txt");
+
+ // Test with just regular option
+ const char *argv2[] = {"test", "-o", "output.log", nullptr};
+ ts::Arguments args2 = parser.parse(argv2);
+ REQUIRE(args2.get("json") == false);
+ REQUIRE(args2.get("xml") == false);
+ REQUIRE(args2.get("output") == true);
+ REQUIRE(args2.get("output").value() == "output.log");
+}
+
+TEST_CASE("Mutex groups - multiple groups", "[mutex_groups]")
+{
+ ts::ArgParser parser;
+ parser.add_description("Test multiple mutex groups");
+ parser.add_global_usage("test [OPTIONS]");
+
+ // First mutex group
+ parser.add_mutex_group("format", false, "Output format");
+ parser.add_option_to_group("format", "--json", "-j", "Output in JSON
format");
+ parser.add_option_to_group("format", "--xml", "-x", "Output in XML format");
+
+ // Second mutex group
+ parser.add_mutex_group("verbosity", false, "Verbosity level");
+ parser.add_option_to_group("verbosity", "--verbose", "-v", "Enable verbose
output");
+ parser.add_option_to_group("verbosity", "--quiet", "-q", "Suppress output");
+
+ // Test with one option from each group
+ const char *argv1[] = {"test", "--json", "--verbose", nullptr};
+ ts::Arguments args1 = parser.parse(argv1);
+ REQUIRE(args1.get("json") == true);
+ REQUIRE(args1.get("xml") == false);
+ REQUIRE(args1.get("verbose") == true);
+ REQUIRE(args1.get("quiet") == false);
+
+ // Test with different options from each group
+ const char *argv2[] = {"test", "-x", "-q", nullptr};
+ ts::Arguments args2 = parser.parse(argv2);
+ REQUIRE(args2.get("xml") == true);
+ REQUIRE(args2.get("json") == false);
+ REQUIRE(args2.get("quiet") == true);
+ REQUIRE(args2.get("verbose") == false);
+}
+class TestArgParser : public ts::ArgParser
+{
+public:
+ TestArgParser() { ts::ArgParser::set_test_mode(true); }
+};
+
+TEST_CASE("Mutex groups - violation detection", "[mutex_groups]")
+{
+ TestArgParser parser;
+ parser.add_mutex_group("format", false, "Output format");
+ parser.add_option_to_group("format", "--json", "-j", "JSON");
+ parser.add_option_to_group("format", "--xml", "-x", "XML");
+
+ // This should trigger validation error
+ const char *argv[] = {"test", "--json", "--xml", nullptr};
+
+ // Need to check that parse() calls help_message() or throws
+ // This may require refactoring help_message to be testable
+ REQUIRE_THROWS(parser.parse(argv)); // Or however errors are handled
+}
+
+TEST_CASE("Mutex groups - required group enforcement", "[mutex_groups]")
+{
+ TestArgParser parser;
+ parser.add_mutex_group("format", true, "Output format (required)");
+ parser.add_option_to_group("format", "--json", "-j", "JSON");
+
+ // No format option provided - should error
+ const char *argv[] = {"test", nullptr};
+
+ REQUIRE_THROWS(parser.parse(argv)); // Or check error handling
+}
+
+TEST_CASE("Mutex groups - with subcommands", "[mutex_groups]")
+{
+ TestArgParser parser;
+ TestArgParser::Command &cmd = parser.add_command("drain", "Drain server");
+
+ cmd.add_mutex_group("drain_mode", false, "Drain mode");
+ cmd.add_option_to_group("drain_mode", "--no-new-connection", "-N", "...");
+ cmd.add_option_to_group("drain_mode", "--undo", "-U", "...");
+
+ const char *argv[] = {"test", "drain", "--undo", nullptr};
+ ts::Arguments args = parser.parse(argv);
+
+ REQUIRE(args.get("drain") == true);
+ REQUIRE(args.get("undo") == true);
+ REQUIRE(args.get("no-new-connection") == false);
+
+ // multiple options in the same group
+ const char *argv2[] = {"test", "drain", "--undo", "--no-new-connection",
nullptr};
+
+ REQUIRE_THROWS(parser.parse(argv2)); // Or check error handling
+}
+
+TEST_CASE("Mutex groups - error when group not created", "[mutex_groups]")
+{
+ TestArgParser parser;
+ // Try to add option to a group that doesn't exist - should throw
+ REQUIRE_THROWS(parser.add_option_to_group("nonexistent", "--test", "-t",
"Test option"));
+}