use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::sync::Arc;
pub trait EnvSource: fmt::Debug + Send + Sync {
fn get(&self, key: &str) -> Option<OsString>;
fn home_fallback(&self) -> Option<std::path::PathBuf> {
None
}
}
pub type SharedEnvSource = Arc<dyn EnvSource>;
#[derive(Debug, Default, Clone, Copy)]
pub struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn get(&self, key: &str) -> Option<OsString> {
std::env::var_os(key)
}
fn home_fallback(&self) -> Option<std::path::PathBuf> {
dirs::home_dir()
}
}
#[derive(Default, Clone)]
pub struct MapEnv {
vars: HashMap<String, OsString>,
}
impl MapEnv {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_var(mut self, key: impl Into<String>, value: impl AsRef<OsStr>) -> Self {
self.vars.insert(key.into(), value.as_ref().to_os_string());
self
}
pub fn insert(&mut self, key: impl Into<String>, value: impl AsRef<OsStr>) {
self.vars.insert(key.into(), value.as_ref().to_os_string());
}
pub fn remove(&mut self, key: &str) {
self.vars.remove(key);
}
}
impl fmt::Debug for MapEnv {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MapEnv")
.field("vars", &self.vars.len())
.finish_non_exhaustive()
}
}
impl EnvSource for MapEnv {
fn get(&self, key: &str) -> Option<OsString> {
self.vars.get(key).cloned()
}
}
impl<K, V> FromIterator<(K, V)> for MapEnv
where
K: Into<String>,
V: AsRef<OsStr>,
{
fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
let vars = iter
.into_iter()
.map(|(key, value)| (key.into(), value.as_ref().to_os_string()))
.collect();
Self { vars }
}
}
#[must_use]
pub fn process_env_source() -> SharedEnvSource {
Arc::new(ProcessEnv)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_env_reports_unset_variables_as_none() {
let env = MapEnv::new().with_var("PRESENT", "yes");
assert!(env.get("ABSENT").is_none());
}
#[test]
fn map_env_collects_from_an_iterator() {
let env: MapEnv = [("APP_HOST", "localhost"), ("APP_PORT", "8080")]
.into_iter()
.collect();
assert_eq!(env.get("APP_PORT").as_deref(), Some("8080".as_ref()));
}
#[test]
fn map_env_remove_makes_a_variable_unset() {
let mut env = MapEnv::new().with_var("APP_HOST", "localhost");
env.remove("APP_HOST");
assert!(env.get("APP_HOST").is_none());
}
#[test]
fn map_env_get_returns_a_value_unaffected_by_later_mutation() {
let mut env = MapEnv::new().with_var("APP_HOST", "localhost");
let taken = env.get("APP_HOST").expect("APP_HOST should be set");
env.insert("APP_HOST", "replaced");
env.remove("APP_HOST");
assert_eq!(
taken,
OsString::from("localhost"),
"the previously returned value must be owned and unchanged"
);
assert!(env.get("APP_HOST").is_none());
}
#[cfg(unix)]
#[test]
fn non_utf8_selector_is_preserved_natively_and_omitted_from_utf8_candidates() {
use std::os::unix::ffi::OsStringExt as _;
use std::path::PathBuf;
let raw = OsString::from_vec(b"/etc/demo-\xFF.toml".to_vec());
let expected = PathBuf::from(raw.clone());
let discovery = crate::ConfigDiscovery::builder("demo")
.env_var("DEMO_CONFIG")
.clear_project_roots()
.add_project_root(std::path::Path::new("/workspace"))
.env_source(Arc::new(MapEnv::new().with_var("DEMO_CONFIG", &raw)))
.build();
let candidates = discovery.candidates();
assert!(
candidates.contains(&expected),
"the native selector path must survive discovery, got {candidates:?}"
);
let utf8 = discovery.utf8_candidates();
assert!(
utf8.iter()
.all(|path| candidates.iter().any(|native| native == path.as_std_path())),
"utf8_candidates must not invent transcoded paths, got {utf8:?}"
);
assert_eq!(
utf8.len() + 1,
candidates.len(),
"exactly the non-UTF-8 candidate must be dropped, got {utf8:?}"
);
}
}