From 6bcea9d2fe070e00831d8fca1da3537cc1003d19 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Sun, 10 May 2026 23:14:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20env=20fallback=20=E2=80=94=20popula?= =?UTF-8?q?te=20unprovided=20options=20from=20environment=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .env field to ArgParameter and ArgImpl. After CLI token dispatch, apply_env_fallback() iterates unprovided options with a non-empty env name, calls getenv(), and runs the normal action pipeline so that conversion and validation still apply. CLI values always take precedence. Env values satisfy required-option checks. Co-Authored-By: Claude Sonnet 4.6 --- include/cli/argument.hh | 5 +++ include/cli/parser.hh | 25 +++++++++++ tests/test_parse.cc | 93 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/include/cli/argument.hh b/include/cli/argument.hh index a84f2ce..1294a41 100644 --- a/include/cli/argument.hh +++ b/include/cli/argument.hh @@ -381,6 +381,8 @@ struct ArgParameter { T default_value{}; ///< Default value when the argument is absent. std::function 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..2850692 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,26 @@ 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; + const char* raw = std::getenv(std::string(f.env).c_str()); + 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..ccc9c2b 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -1,5 +1,6 @@ #include +#include #include #include @@ -308,3 +309,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); +} From 71e119b484baf801862f0228227c3e5c6e82114f Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Sun, 10 May 2026 23:17:25 +0900 Subject: [PATCH 2/6] style: apply nix fmt Co-Authored-By: Claude Sonnet 4.6 --- include/cli/argument.hh | 2 +- tests/test_parse.cc | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/include/cli/argument.hh b/include/cli/argument.hh index 1294a41..c6f4b0f 100644 --- a/include/cli/argument.hh +++ b/include/cli/argument.hh @@ -380,7 +380,7 @@ 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. }; diff --git a/tests/test_parse.cc b/tests/test_parse.cc index ccc9c2b..c558eda 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -325,8 +325,8 @@ struct EnvArgs { TEST(ParseEnv, FallsBackToEnvWhenOptionAbsent) { setenv("TEST_OUTPUT", "from_env", 1); auto args = argv({"prog"}); - auto result = cli::Parser{}.parse( - std::span(args), 1); + 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()); @@ -336,8 +336,8 @@ TEST(ParseEnv, FallsBackToEnvWhenOptionAbsent) { 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); + 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()); @@ -374,8 +374,8 @@ TEST(ParseEnv, MissingEnvStillFailsForRequired) { TEST(ParseEnv, IntOptionFromEnv) { setenv("TEST_COUNT", "42", 1); auto args = argv({"prog"}); - auto result = cli::Parser{}.parse( - std::span(args), 1); + 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()); @@ -385,8 +385,8 @@ TEST(ParseEnv, IntOptionFromEnv) { TEST(ParseEnv, FlagFromEnv) { setenv("TEST_VERBOSE", "1", 1); auto args = argv({"prog"}); - auto result = cli::Parser{}.parse( - std::span(args), 1); + auto result = + cli::Parser{}.parse(std::span(args), 1); unsetenv("TEST_VERBOSE"); ASSERT_TRUE(result.has_value()); EXPECT_TRUE(result.value.verbose.value()); @@ -395,8 +395,8 @@ TEST(ParseEnv, FlagFromEnv) { TEST(ParseEnv, InvalidEnvValueReportsError) { setenv("TEST_COUNT", "not_a_number", 1); auto args = argv({"prog"}); - auto result = cli::Parser{}.parse( - std::span(args), 1); + 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); From ce329ebd836863a5ffec1101b9e6ccd9010b863b Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Sun, 10 May 2026 23:23:20 +0900 Subject: [PATCH 3/6] fix: use _dupenv_s on Windows to avoid C4996 getenv warning Co-Authored-By: Claude Sonnet 4.6 --- include/cli/parser.hh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/include/cli/parser.hh b/include/cli/parser.hh index 2850692..e0d5c61 100644 --- a/include/cli/parser.hh +++ b/include/cli/parser.hh @@ -934,7 +934,15 @@ struct Parser { 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) { From 2eda3f747dfc9cfd964af572a4c0ac5ad295c867 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Sun, 10 May 2026 23:32:06 +0900 Subject: [PATCH 4/6] fix: add Windows setenv/unsetenv compatibility wrappers in test_parse.cc Co-Authored-By: Claude Sonnet 4.6 --- tests/test_parse.cc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_parse.cc b/tests/test_parse.cc index c558eda..42803cd 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -4,6 +4,17 @@ #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +static auto setenv(const char* name, const char* value, int) -> int { + return SetEnvironmentVariableA(name, value) ? 0 : -1; +} +static auto unsetenv(const char* name) -> void { + SetEnvironmentVariableA(name, nullptr); +} +#endif + #include "cli/argument.hh" #include "cli/parser.hh" From 16b53219076f663edc89b878ded79e895bdcaf82 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Sun, 10 May 2026 23:39:09 +0900 Subject: [PATCH 5/6] fix: add NOMINMAX to prevent min/max macro collision on Windows Co-Authored-By: Claude Sonnet 4.6 --- tests/test_parse.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_parse.cc b/tests/test_parse.cc index 42803cd..7a20131 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -6,6 +6,7 @@ #ifdef _WIN32 #define WIN32_LEAN_AND_MEAN +#define NOMINMAX #include static auto setenv(const char* name, const char* value, int) -> int { return SetEnvironmentVariableA(name, value) ? 0 : -1; From e088629aefa081754ad46fe5aeae37cd66388006 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Sun, 10 May 2026 23:44:34 +0900 Subject: [PATCH 6/6] fix: use _putenv_s for setenv/unsetenv wrappers on Windows SetEnvironmentVariableA only updates the OS environment block, not the CRT's _environ table that _dupenv_s reads from. _putenv_s updates both. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_parse.cc | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/test_parse.cc b/tests/test_parse.cc index 7a20131..68fcd8a 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -5,15 +5,11 @@ #include #ifdef _WIN32 -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include +#include static auto setenv(const char* name, const char* value, int) -> int { - return SetEnvironmentVariableA(name, value) ? 0 : -1; -} -static auto unsetenv(const char* name) -> void { - SetEnvironmentVariableA(name, nullptr); + return _putenv_s(name, value); } +static auto unsetenv(const char* name) -> void { _putenv_s(name, ""); } #endif #include "cli/argument.hh"