diff --git a/clap_builder/src/builder/arg.rs b/clap_builder/src/builder/arg.rs index a8d2a689e22..6e47de29481 100644 --- a/clap_builder/src/builder/arg.rs +++ b/clap_builder/src/builder/arg.rs @@ -85,6 +85,8 @@ pub struct Arg { pub(crate) default_missing_vals: Vec, #[cfg(feature = "env")] pub(crate) env: Option<(OsStr, Option)>, + #[cfg(all(feature = "env", feature = "string"))] + pub(crate) env_prefix: Option>, pub(crate) terminator: Option, pub(crate) index: Option, pub(crate) help_heading: Option>, @@ -2221,6 +2223,41 @@ impl Arg { pub fn env_os(self, name: impl Into) -> Self { self.env(name) } + + /// Sets an env variable prefix for this argument. + /// + /// When set, the env variable name specified via [`Arg::env`] will be + /// prefixed with this value (joined by `_`) during build. + /// + /// An explicit `Arg::env_prefix` takes precedence over + /// [`Command::next_env_prefix`]. + /// + /// This can be reset with `None`. + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(all(feature = "env", feature = "string"))] { + /// # use clap_builder as clap; + /// # use clap::{Command, Arg}; + /// let cmd = Command::new("myapp") + /// .arg(Arg::new("config") + /// .long("config") + /// .env("CONFIG") + /// .env_prefix("MYAPP")); + /// // env var will be MYAPP_CONFIG + /// # } + /// ``` + /// + /// [`Arg::env`]: Arg::env() + /// [`Command::next_env_prefix`]: crate::Command::next_env_prefix() + #[cfg(all(feature = "env", feature = "string"))] + #[inline] + #[must_use] + pub fn env_prefix(mut self, prefix: impl IntoResettable) -> Self { + self.env_prefix = Some(prefix.into_resettable().into_option()); + self + } } /// # Help @@ -4407,6 +4444,18 @@ impl Arg { self.env.as_ref().map(|x| x.0.as_os_str()) } + /// Get the env variable prefix for this argument, if any. + /// + /// See [`Arg::env_prefix`]. + #[cfg(all(feature = "env", feature = "string"))] + #[inline] + pub fn get_env_prefix(&self) -> Option<&std::ffi::OsStr> { + self.env_prefix + .as_ref() + .and_then(|p| p.as_ref()) + .map(|p| p.as_os_str()) + } + /// Get the default values specified for this argument, if any /// /// # Examples @@ -4829,6 +4878,10 @@ impl fmt::Debug for Arg { { ds = ds.field("env", &self.env); } + #[cfg(all(feature = "env", feature = "string"))] + { + ds = ds.field("env_prefix", &self.env_prefix); + } ds.finish() } diff --git a/clap_builder/src/builder/command.rs b/clap_builder/src/builder/command.rs index 67c9556a831..48afe74e5fd 100644 --- a/clap_builder/src/builder/command.rs +++ b/clap_builder/src/builder/command.rs @@ -11,6 +11,8 @@ use std::path::Path; // Internal use crate::builder::ArgAction; use crate::builder::IntoResettable; +#[cfg(all(feature = "env", feature = "string"))] +use crate::builder::OsStr; use crate::builder::PossibleValue; use crate::builder::Str; use crate::builder::StyledStr; @@ -101,6 +103,8 @@ pub struct Command { subcommands: Vec, groups: Vec, current_help_heading: Option, + #[cfg(all(feature = "env", feature = "string"))] + current_env_prefix: Option, current_disp_ord: Option, subcommand_value_name: Option, subcommand_heading: Option, @@ -185,6 +189,11 @@ impl Command { arg.help_heading .get_or_insert_with(|| self.current_help_heading.clone()); + #[cfg(all(feature = "env", feature = "string"))] + { + arg.env_prefix + .get_or_insert_with(|| self.current_env_prefix.clone()); + } self.args.push(arg); } @@ -2378,6 +2387,41 @@ impl Command { self } + /// Sets a prefix to be prepended to the environment variable names of all + /// subsequent arguments added to this command. + /// + /// This is a stateful method that affects all future [`Arg`]s added via + /// [`Command::arg`]. An explicit [`Arg::env_prefix`] on an argument takes + /// precedence over this. + /// + /// The prefix and the argument's env name will be joined with `_`. + /// + /// This is modeled after [`Command::next_help_heading`]. + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(all(feature = "env", feature = "string"))] { + /// # use clap_builder as clap; + /// # use clap::{Command, Arg}; + /// let cmd = Command::new("myapp") + /// .next_env_prefix("MYAPP") + /// .arg(Arg::new("config").long("config").env("CONFIG")) + /// .arg(Arg::new("verbose").long("verbose")); + /// // config's env var will be MYAPP_CONFIG + /// # } + /// ``` + /// + /// [`Command::arg`]: Command::arg() + /// [`Arg::env_prefix`]: crate::Arg::env_prefix() + #[cfg(all(feature = "env", feature = "string"))] + #[inline] + #[must_use] + pub fn next_env_prefix(mut self, prefix: impl IntoResettable) -> Self { + self.current_env_prefix = prefix.into_resettable().into_option(); + self + } + /// Change the starting value for assigning future display orders for args. /// /// This will be used for any arg that hasn't had [`Arg::display_order`] called. @@ -3834,6 +3878,13 @@ impl Command { self.current_help_heading.as_deref() } + /// Get the env prefix specified via [`Command::next_env_prefix`]. + #[cfg(all(feature = "env", feature = "string"))] + #[inline] + pub fn get_next_env_prefix(&self) -> Option<&std::ffi::OsStr> { + self.current_env_prefix.as_ref().map(|s| s.as_os_str()) + } + /// Iterate through the *visible* aliases for this subcommand. #[inline] pub fn get_visible_aliases(&self) -> impl Iterator + '_ { @@ -4440,6 +4491,18 @@ impl Command { } } + // Apply env prefix to env variable names + #[cfg(all(feature = "env", feature = "string"))] + if let Some(Some(ref prefix)) = a.env_prefix { + if let Some((ref env_name, _)) = a.env { + let mut prefixed = prefix.to_os_string(); + prefixed.push("_"); + prefixed.push(env_name.as_os_str()); + let value = env::var_os(&prefixed); + a.env = Some((OsStr::from_string(prefixed), value)); + } + } + // Figure out implied settings a._build(); if hide_pv && a.is_takes_value_set() { @@ -5221,6 +5284,8 @@ impl Default for Command { subcommands: Default::default(), groups: Default::default(), current_help_heading: Default::default(), + #[cfg(all(feature = "env", feature = "string"))] + current_env_prefix: Default::default(), current_disp_ord: Some(0), subcommand_value_name: Default::default(), subcommand_heading: Default::default(), diff --git a/clap_derive/src/attr.rs b/clap_derive/src/attr.rs index 1f8a1d26c7c..aaba768b719 100644 --- a/clap_derive/src/attr.rs +++ b/clap_derive/src/attr.rs @@ -81,6 +81,7 @@ impl Parse for ClapAttr { "skip" => Some(MagicAttrName::Skip), "next_display_order" => Some(MagicAttrName::NextDisplayOrder), "next_help_heading" => Some(MagicAttrName::NextHelpHeading), + "next_env_prefix" => Some(MagicAttrName::NextEnvPrefix), "default_value_t" => Some(MagicAttrName::DefaultValueT), "default_values_t" => Some(MagicAttrName::DefaultValuesT), "default_value_os_t" => Some(MagicAttrName::DefaultValueOsT), @@ -167,6 +168,7 @@ pub(crate) enum MagicAttrName { DefaultValuesOsT, NextDisplayOrder, NextHelpHeading, + NextEnvPrefix, } #[derive(Clone)] diff --git a/clap_derive/src/derives/args.rs b/clap_derive/src/derives/args.rs index d1162c6077f..04c278690bf 100644 --- a/clap_derive/src/derives/args.rs +++ b/clap_derive/src/derives/args.rs @@ -227,6 +227,7 @@ pub(crate) fn gen_augment( }; let next_help_heading = item.next_help_heading(); + let next_env_prefix = item.next_env_prefix(); let next_display_order = item.next_display_order(); let flatten_group_assert = if matches!(**ty, Ty::Option) { quote_spanned! { kind.span()=> @@ -240,7 +241,8 @@ pub(crate) fn gen_augment( #flatten_group_assert let #app_var = #app_var #next_help_heading - #next_display_order; + #next_display_order + #next_env_prefix; let #app_var = <#inner_type as clap::Args>::augment_args_for_update(#app_var); }) } else { @@ -248,7 +250,8 @@ pub(crate) fn gen_augment( #flatten_group_assert let #app_var = #app_var #next_help_heading - #next_display_order; + #next_display_order + #next_env_prefix; let #app_var = <#inner_type as clap::Args>::augment_args(#app_var); }) } diff --git a/clap_derive/src/derives/subcommand.rs b/clap_derive/src/derives/subcommand.rs index 8b9584ba068..cc8557dc4bf 100644 --- a/clap_derive/src/derives/subcommand.rs +++ b/clap_derive/src/derives/subcommand.rs @@ -188,13 +188,15 @@ fn gen_augment( quote!() }; let next_help_heading = item.next_help_heading(); + let next_env_prefix = item.next_env_prefix(); let next_display_order = item.next_display_order(); let subcommand = if override_required { quote! { #deprecations let #app_var = #app_var #next_help_heading - #next_display_order; + #next_display_order + #next_env_prefix; let #app_var = <#ty as clap::Subcommand>::augment_subcommands_for_update(#app_var); } } else { @@ -202,7 +204,8 @@ fn gen_augment( #deprecations let #app_var = #app_var #next_help_heading - #next_display_order; + #next_display_order + #next_env_prefix; let #app_var = <#ty as clap::Subcommand>::augment_subcommands(#app_var); } }; diff --git a/clap_derive/src/item.rs b/clap_derive/src/item.rs index e527cc0ca8b..7f7192e739b 100644 --- a/clap_derive/src/item.rs +++ b/clap_derive/src/item.rs @@ -44,6 +44,7 @@ pub(crate) struct Item { force_long_help: bool, next_display_order: Option, next_help_heading: Option, + next_env_prefix: Option, is_enum: bool, is_positional: bool, skip_group: bool, @@ -273,6 +274,7 @@ impl Item { force_long_help: false, next_display_order: None, next_help_heading: None, + next_env_prefix: None, is_enum: false, is_positional: true, skip_group: false, @@ -819,6 +821,13 @@ impl Item { self.next_help_heading = Some(Method::new(attr.name.clone(), quote!(#expr))); } + Some(MagicAttrName::NextEnvPrefix) => { + assert_attr_kind(attr, &[AttrKind::Command])?; + + let expr = attr.value_or_abort()?; + self.next_env_prefix = Some(Method::new(attr.name.clone(), quote!(#expr))); + } + Some(MagicAttrName::RenameAll) => { let lit = attr.lit_str_or_abort()?; self.casing = CasingStyle::from_lit(lit)?; @@ -967,9 +976,11 @@ impl Item { pub(crate) fn initial_top_level_methods(&self) -> TokenStream { let next_display_order = self.next_display_order.as_ref().into_iter(); let next_help_heading = self.next_help_heading.as_ref().into_iter(); + let next_env_prefix = self.next_env_prefix.as_ref().into_iter(); quote!( #(#next_display_order)* #(#next_help_heading)* + #(#next_env_prefix)* ) } @@ -1011,6 +1022,11 @@ impl Item { quote!( #(#next_help_heading)* ) } + pub(crate) fn next_env_prefix(&self) -> TokenStream { + let next_env_prefix = self.next_env_prefix.as_ref().into_iter(); + quote!( #(#next_env_prefix)* ) + } + pub(crate) fn id(&self) -> &Name { &self.name } diff --git a/tests/builder/env.rs b/tests/builder/env.rs index affce80be63..db21457a1b7 100644 --- a/tests/builder/env.rs +++ b/tests/builder/env.rs @@ -474,3 +474,142 @@ fn value_parser_invalid() { assert!(r.is_err()); } + +#[cfg(feature = "string")] +#[test] +fn env_prefix_basic() { + env::set_var("MYAPP_CONFIG", "test_value"); + + let r = Command::new("myapp") + .next_env_prefix("MYAPP") + .arg( + Arg::new("config") + .long("config") + .env("CONFIG") + .action(ArgAction::Set), + ) + .try_get_matches_from(vec![""]); + + assert!(r.is_ok(), "{}", r.unwrap_err()); + let m = r.unwrap(); + assert_eq!( + m.get_one::("config").map(|v| v.as_str()).unwrap(), + "test_value" + ); +} + +#[cfg(feature = "string")] +#[test] +fn env_prefix_multiple_args() { + env::set_var("APP_HOST", "localhost"); + env::set_var("APP_PORT", "8080"); + + let r = Command::new("app") + .next_env_prefix("APP") + .arg( + Arg::new("host") + .long("host") + .env("HOST") + .action(ArgAction::Set), + ) + .arg( + Arg::new("port") + .long("port") + .env("PORT") + .action(ArgAction::Set), + ) + .try_get_matches_from(vec![""]); + + assert!(r.is_ok(), "{}", r.unwrap_err()); + let m = r.unwrap(); + assert_eq!( + m.get_one::("host").map(|v| v.as_str()).unwrap(), + "localhost" + ); + assert_eq!( + m.get_one::("port").map(|v| v.as_str()).unwrap(), + "8080" + ); +} + +#[cfg(feature = "string")] +#[test] +fn env_prefix_reset() { + env::set_var("PFX_FIRST", "val1"); + + let r = Command::new("app") + .next_env_prefix("PFX") + .arg( + Arg::new("first") + .long("first") + .env("FIRST") + .action(ArgAction::Set), + ) + .next_env_prefix(None) + .arg( + Arg::new("second") + .long("second") + .env("SECOND") + .action(ArgAction::Set) + .default_value("default"), + ) + .try_get_matches_from(vec![""]); + + assert!(r.is_ok(), "{}", r.unwrap_err()); + let m = r.unwrap(); + assert_eq!( + m.get_one::("first").map(|v| v.as_str()).unwrap(), + "val1" + ); + assert_eq!( + m.get_one::("second").map(|v| v.as_str()).unwrap(), + "default" + ); +} + +#[cfg(feature = "string")] +#[test] +fn env_prefix_arg_level() { + env::set_var("CUSTOM_DB", "mydb"); + + let r = Command::new("app") + .arg( + Arg::new("db") + .long("db") + .env("DB") + .env_prefix("CUSTOM") + .action(ArgAction::Set), + ) + .try_get_matches_from(vec![""]); + + assert!(r.is_ok(), "{}", r.unwrap_err()); + let m = r.unwrap(); + assert_eq!( + m.get_one::("db").map(|v| v.as_str()).unwrap(), + "mydb" + ); +} + +#[cfg(feature = "string")] +#[test] +fn env_prefix_arg_overrides_command() { + env::set_var("OVERRIDE_HOST", "overridden"); + + let r = Command::new("app") + .next_env_prefix("APP") + .arg( + Arg::new("host") + .long("host") + .env("HOST") + .env_prefix("OVERRIDE") + .action(ArgAction::Set), + ) + .try_get_matches_from(vec![""]); + + assert!(r.is_ok(), "{}", r.unwrap_err()); + let m = r.unwrap(); + assert_eq!( + m.get_one::("host").map(|v| v.as_str()).unwrap(), + "overridden" + ); +} diff --git a/tests/derive/env_prefix.rs b/tests/derive/env_prefix.rs new file mode 100644 index 00000000000..4d438538c65 --- /dev/null +++ b/tests/derive/env_prefix.rs @@ -0,0 +1,86 @@ +#![cfg(feature = "env")] +#![cfg(feature = "string")] + +use clap::{Args, CommandFactory, Parser}; +use std::env; + +#[test] +fn command_next_env_prefix_applied() { + #[derive(Debug, Clone, Parser)] + #[command(next_env_prefix = "MYAPP")] + struct CliOptions { + #[arg(long, env = "CONFIG")] + config: Option, + + #[arg(long)] + verbose: bool, + } + + let cmd = CliOptions::command(); + + let config_arg = cmd + .get_arguments() + .find(|a| a.get_id() == "config") + .unwrap(); + assert_eq!( + config_arg.get_env_prefix(), + Some(std::ffi::OsStr::new("MYAPP")) + ); +} + +#[test] +fn command_next_env_prefix_value_resolved() { + env::set_var("DERIVE_APP_HOST", "localhost"); + + #[derive(Debug, Clone, Parser)] + #[command(next_env_prefix = "DERIVE_APP")] + struct CliOptions { + #[arg(long, env = "HOST")] + host: Option, + } + + let m = CliOptions::try_parse_from(vec![""]).unwrap(); + assert_eq!(m.host.as_deref(), Some("localhost")); +} + +#[test] +fn command_next_env_prefix_with_flatten() { + env::set_var("FLAT_APP_DB", "mydb"); + + #[derive(Debug, Clone, Args)] + #[command(next_env_prefix = "FLAT_APP")] + struct DbArgs { + #[arg(long, env = "DB")] + db: Option, + } + + #[derive(Debug, Clone, Parser)] + struct CliOptions { + #[command(flatten)] + db_args: DbArgs, + } + + let m = CliOptions::try_parse_from(vec![""]).unwrap(); + assert_eq!(m.db_args.db.as_deref(), Some("mydb")); +} + +#[test] +fn flatten_field_with_env_prefix() { + env::set_var("FIELD_PFX_PORT", "9090"); + + #[derive(Debug, Clone, Args)] + struct ServerArgs { + #[arg(long, env = "PORT")] + port: Option, + } + + #[derive(Debug, Clone, Parser)] + struct CliOptions { + #[command(flatten)] + #[command(next_env_prefix = "FIELD_PFX")] + server: ServerArgs, + } + + let m = CliOptions::try_parse_from(vec![""]).unwrap(); + assert_eq!(m.server.port.as_deref(), Some("9090")); +}