use std::{env::VarError, sync::Arc};
use minijinja::{Error, ErrorKind};
use mockable::{DefaultEnv, Env};
use crate::localization::{self, keys};
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub enum EnvReadError {
#[error("environment variable is not present")]
NotPresent,
#[error("environment variable contains invalid UTF-8")]
NotUnicode,
}
impl From<VarError> for EnvReadError {
fn from(error: VarError) -> Self {
match error {
VarError::NotPresent => Self::NotPresent,
VarError::NotUnicode(_) => Self::NotUnicode,
}
}
}
pub type EnvReader = Arc<dyn Fn(&str) -> Result<String, EnvReadError> + Send + Sync>;
#[must_use]
pub fn process_env_reader() -> EnvReader {
let env = DefaultEnv;
Arc::new(move |key| env.raw(key).map_err(EnvReadError::from))
}
pub(super) fn env_var_with(
name: &str,
read_env: impl FnOnce(&str) -> Result<String, EnvReadError>,
) -> Result<String, Error> {
match read_env(name) {
Ok(value) => Ok(value),
Err(EnvReadError::NotPresent) => {
tracing::debug!(failure_kind = "not_present", "manifest env lookup failed");
Err(Error::new(
ErrorKind::UndefinedError,
localization::message(keys::MANIFEST_ENV_MISSING).to_string(),
))
}
Err(EnvReadError::NotUnicode) => {
tracing::debug!(failure_kind = "not_unicode", "manifest env lookup failed");
Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::MANIFEST_ENV_INVALID_UTF8).to_string(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_tracing_capture::with_test_subscriber;
use rstest::rstest;
use tracing_subscriber::filter::LevelFilter;
const SENTINEL: &str = "s3cr3t-sentinel";
#[rstest]
#[case::not_present(EnvReadError::NotPresent, "not_present")]
#[case::not_unicode(EnvReadError::NotUnicode, "not_unicode")]
fn lookup_failures_trace_only_a_bounded_failure_kind(
#[case] failure: EnvReadError,
#[case] failure_kind: &str,
) {
let events = with_test_subscriber(LevelFilter::DEBUG, |captured| {
env_var_with(SENTINEL, |_| Err(failure)).expect_err("the injected reader must fail");
captured.snapshot()
});
assert!(
events
.iter()
.any(|event| event.contains("manifest env lookup failed")
&& event.contains(&format!("failure_kind=\"{failure_kind}\""))),
"expected a bounded lookup-failure event in {events:?}"
);
assert!(
!events.iter().any(|event| event.contains(SENTINEL)),
"the variable name must not be logged: {events:?}"
);
}
#[rstest]
#[case::not_present(EnvReadError::NotPresent, ErrorKind::UndefinedError)]
#[case::not_unicode(EnvReadError::NotUnicode, ErrorKind::InvalidOperation)]
fn lookup_failures_omit_the_variable_name_from_the_error(
#[case] failure: EnvReadError,
#[case] expected_kind: ErrorKind,
) {
let error =
env_var_with(SENTINEL, |_| Err(failure)).expect_err("the injected reader must fail");
assert_eq!(
error.kind(),
expected_kind,
"the Jinja error kind must be preserved"
);
assert!(
!error.to_string().contains(SENTINEL),
"the variable name must not reach the error: {error}"
);
}
#[test]
fn process_reader_matches_default_environment_adapter() {
let expected = DefaultEnv.raw("PATH").map_err(EnvReadError::from);
let actual = process_env_reader()("PATH");
assert_eq!(
actual, expected,
"process reader should delegate to DefaultEnv"
);
}
}