diff --git a/include/cli/argument.hh b/include/cli/argument.hh index a84f2ce..c6f4b0f 100644 --- a/include/cli/argument.hh +++ b/include/cli/argument.hh @@ -380,7 +380,9 @@ struct ArgParameter { Presence presence{Presence::optional}; ///< Whether the argument is required. T default_value{}; ///< Default value when the argument is absent. std::function - on_parse{}; ///< Callback invoked once after parsing completes. + on_parse{}; ///< Callback invoked once after parsing completes. + std::string_view env{}; ///< Environment variable name to fall back to when + ///< the option is absent from the command line. }; namespace detail { @@ -400,6 +402,7 @@ struct ArgImpl : public OptionTag { ArgImpl(ArgParameter param) : help(param.help), presence(param.presence), + env(param.env), value_(param.default_value), on_parse_(std::move(param.on_parse)) {} @@ -430,6 +433,8 @@ struct ArgImpl : public OptionTag { // NOLINTNEXTLINE(cppcoreguidelines-non-private-member-variables-in-classes) cli::Presence presence{Presence::optional}; // NOLINTNEXTLINE(cppcoreguidelines-non-private-member-variables-in-classes) + std::string_view env{}; + // NOLINTNEXTLINE(cppcoreguidelines-non-private-member-variables-in-classes) value_type default_value{}; static constexpr auto name = Name; diff --git a/include/cli/parser.hh b/include/cli/parser.hh index 7bbea7d..e0d5c61 100644 --- a/include/cli/parser.hh +++ b/include/cli/parser.hh @@ -717,6 +717,11 @@ struct Parser { } }); + if (auto err = apply_env_fallback(result.value); err.has_error()) { + result.error = std::move(err.error); + return; + } + if (auto err = validate_required(result.value); err.has_error()) { result.error = std::move(err.error); } @@ -923,6 +928,34 @@ struct Parser { return res; } + auto apply_env_fallback(T& val) -> ActionResult { + ActionResult res = ActionResult::ok(); + for_each_field(val, [&](auto& f) -> auto { + using F = std::remove_cvref_t; + if constexpr (std::derived_from) { + if (res.has_error() || f.provided() || f.env.empty()) return; +#ifdef _WIN32 + char* raw_buf = nullptr; + std::size_t raw_len = 0; + _dupenv_s(&raw_buf, &raw_len, std::string(f.env).c_str()); + std::unique_ptr raw_guard(raw_buf, &free); + const char* raw = raw_buf; +#else + const char* raw = std::getenv(std::string(f.env).c_str()); +#endif + if (!raw) return; + f.notify_option_seen(); + if constexpr (F::nargs.min == 0 && F::nargs.max == 0) { + res = f.invoke_flag(0); + } else { + res = f.invoke_action(std::string_view(raw), 0); + } + if (!res.has_error()) f.fire_on_parse(); + } + }); + return res; + } + auto finalize_special_error(ParseError error) -> ParseError { if (error.code == ErrorCode::help_requested && error.detail.empty()) { error.detail = format_help(ColorMode::never); diff --git a/tests/test_parse.cc b/tests/test_parse.cc index 6048e3f..68fcd8a 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -1,8 +1,17 @@ #include +#include #include #include +#ifdef _WIN32 +#include +static auto setenv(const char* name, const char* value, int) -> int { + return _putenv_s(name, value); +} +static auto unsetenv(const char* name) -> void { _putenv_s(name, ""); } +#endif + #include "cli/argument.hh" #include "cli/parser.hh" @@ -308,3 +317,95 @@ TEST(ParseClusterNargs2, FlagsThenValueNextTokens) { (std::vector{"file1", "file2"})); EXPECT_EQ(result.value.files.value(), (std::vector{"file3"})); } + +// ---- env fallback ---------------------------------------------------------- + +namespace { + +struct EnvArgs { + cli::StringOption<"output", 'o'> output{{.env = "TEST_OUTPUT"}}; + cli::IntOption<"count", 'c'> count{{.env = "TEST_COUNT"}}; + cli::Flag<"verbose", 'v'> verbose{{.env = "TEST_VERBOSE"}}; +}; + +} // namespace + +TEST(ParseEnv, FallsBackToEnvWhenOptionAbsent) { + setenv("TEST_OUTPUT", "from_env", 1); + auto args = argv({"prog"}); + auto result = + cli::Parser{}.parse(std::span(args), 1); + unsetenv("TEST_OUTPUT"); + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result.value.output.value().has_value()); + EXPECT_EQ(*result.value.output.value(), "from_env"); +} + +TEST(ParseEnv, CliTakesPrecedenceOverEnv) { + setenv("TEST_OUTPUT", "from_env", 1); + auto args = argv({"prog", "--output", "from_cli"}); + auto result = + cli::Parser{}.parse(std::span(args), 1); + unsetenv("TEST_OUTPUT"); + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result.value.output.value().has_value()); + EXPECT_EQ(*result.value.output.value(), "from_cli"); +} + +TEST(ParseEnv, EnvSatisfiesRequiredOption) { + struct RequiredEnvArgs { + cli::StringOption<"output"> output{ + {.presence = cli::required, .env = "TEST_OUTPUT"}}; + }; + setenv("TEST_OUTPUT", "env_val", 1); + auto args = argv({"prog"}); + auto result = cli::Parser{}.parse( + std::span(args), 1); + unsetenv("TEST_OUTPUT"); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result.value.output.value(), "env_val"); +} + +TEST(ParseEnv, MissingEnvStillFailsForRequired) { + struct RequiredEnvArgs { + cli::StringOption<"output"> output{ + {.presence = cli::required, .env = "TEST_OUTPUT_ABSENT"}}; + }; + unsetenv("TEST_OUTPUT_ABSENT"); + auto args = argv({"prog"}); + auto result = cli::Parser{}.parse( + std::span(args), 1); + ASSERT_TRUE(result.has_error()); + EXPECT_EQ(result.error.code, cli::ErrorCode::missing_required); +} + +TEST(ParseEnv, IntOptionFromEnv) { + setenv("TEST_COUNT", "42", 1); + auto args = argv({"prog"}); + auto result = + cli::Parser{}.parse(std::span(args), 1); + unsetenv("TEST_COUNT"); + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result.value.count.value().has_value()); + EXPECT_EQ(*result.value.count.value(), 42); +} + +TEST(ParseEnv, FlagFromEnv) { + setenv("TEST_VERBOSE", "1", 1); + auto args = argv({"prog"}); + auto result = + cli::Parser{}.parse(std::span(args), 1); + unsetenv("TEST_VERBOSE"); + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result.value.verbose.value()); +} + +TEST(ParseEnv, InvalidEnvValueReportsError) { + setenv("TEST_COUNT", "not_a_number", 1); + auto args = argv({"prog"}); + auto result = + cli::Parser{}.parse(std::span(args), 1); + unsetenv("TEST_COUNT"); + ASSERT_TRUE(result.has_error()); + EXPECT_EQ(result.error.code, cli::ErrorCode::invalid_value); +}