use std::ffi::{OsStr, OsString};
use std::fmt;
use std::process::Command;
#[cfg(windows)]
pub(super) fn env_names_eq(left: &OsStr, right: &OsStr) -> bool {
left.eq_ignore_ascii_case(right)
}
#[cfg(not(windows))]
pub(super) fn env_names_eq(left: &OsStr, right: &OsStr) -> bool {
left == right
}
#[derive(Default, Clone, PartialEq, Eq)]
pub struct CommandEnv {
vars: Vec<(OsString, OsString)>,
}
impl fmt::Debug for CommandEnv {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommandEnv")
.field("override_count", &self.vars.len())
.field("path_overridden", &self.is_path_overridden())
.finish_non_exhaustive()
}
}
impl CommandEnv {
#[must_use]
pub fn inherit() -> Self {
Self::default()
}
#[must_use]
pub fn with_var(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
let name = key.as_ref().to_os_string();
let setting = value.as_ref().to_os_string();
if let Some(existing) = self
.vars
.iter_mut()
.find(|(existing, _)| env_names_eq(existing, &name))
{
existing.1 = setting;
} else {
self.vars.push((name, setting));
}
self
}
#[must_use]
pub fn with_path(self, path: impl AsRef<OsStr>) -> Self {
self.with_var("PATH", path)
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.vars.is_empty()
}
#[must_use]
pub fn get(&self, key: impl AsRef<OsStr>) -> Option<&OsStr> {
let wanted = key.as_ref();
self.vars
.iter()
.find(|(name, _)| env_names_eq(name, wanted))
.map(|(_, value)| value.as_os_str())
}
fn is_path_overridden(&self) -> bool {
self.get("PATH").is_some()
}
pub(crate) fn apply(&self, cmd: &mut Command) {
for (key, value) in &self.vars {
cmd.env(key, value);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn differently_cased_keys_are_distinct_variables() {
let env = CommandEnv::inherit()
.with_var("Path", "/mixed")
.with_path("/upper");
assert_eq!(env.get("Path"), Some(OsStr::new("/mixed")));
assert_eq!(env.get("PATH"), Some(OsStr::new("/upper")));
}
#[cfg(windows)]
#[test]
fn differently_cased_keys_denote_one_variable() {
let env = CommandEnv::inherit()
.with_var("Path", "old")
.with_path("new");
assert_eq!(env.get("Path"), Some(OsStr::new("new")));
assert_eq!(env.get("PATH"), Some(OsStr::new("new")));
}
#[test]
fn debug_redacts_override_names_and_values() {
let env = CommandEnv::inherit()
.with_var("NETSUKE_API_TOKEN", "s3cr3t-value")
.with_path("/opt/toolchain/bin");
let rendered = format!("{env:?}");
assert!(
!rendered.contains("NETSUKE_API_TOKEN"),
"an override name leaked into Debug output: {rendered}"
);
assert!(
!rendered.contains("s3cr3t-value"),
"an override value leaked into Debug output: {rendered}"
);
assert!(
!rendered.contains("/opt/toolchain/bin"),
"a PATH value leaked into Debug output: {rendered}"
);
assert!(
rendered.contains("override_count: 2") && rendered.contains("path_overridden: true"),
"Debug output should still summarize the overrides: {rendered}"
);
}
#[test]
fn debug_reports_the_inherited_shape() {
let rendered = format!("{:?}", CommandEnv::inherit());
assert!(
rendered.contains("override_count: 0") && rendered.contains("path_overridden: false"),
"unexpected Debug output: {rendered}"
);
}
}