use std::{collections::BTreeSet, path::Path, sync::Arc};
use serde::{Deserialize, Serialize};
use crate::{
error::ValidationError,
eval::registry::FuncRegistry,
ir::{Map, Value},
};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "mode")]
#[non_exhaustive]
pub enum EnvVarMode {
Passthrough,
Strict {
allowed: BTreeSet<Arc<str>>,
},
Mock,
}
impl Default for EnvVarMode {
fn default() -> Self {
Self::Strict {
allowed: BTreeSet::new(),
}
}
}
impl EnvVarMode {
#[must_use]
pub fn allows(&self, name: &str) -> bool {
match self {
Self::Passthrough => true,
Self::Strict { allowed } => allowed.iter().any(|s| s.as_ref() == name),
Self::Mock => false,
}
}
#[must_use]
pub const fn is_mock(&self) -> bool {
matches!(self, Self::Mock)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct EvalLimits {
pub max_func_args: u32,
pub max_str_size: u32,
pub max_list_len: u32,
pub max_iterations: u32,
pub max_file_bytes: u32,
}
impl Default for EvalLimits {
fn default() -> Self {
Self {
max_func_args: 64,
max_str_size: 1 << 20,
max_list_len: 100_000,
max_iterations: 1_000_000,
max_file_bytes: 4 << 20,
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct EvalContext {
pub workspace_root: Arc<Path>,
pub environment: Option<Arc<str>>,
pub env_vars: EnvVarMode,
pub repo_vars: Map,
pub cascade_locals: Map,
pub funcs: Arc<FuncRegistry>,
pub limits: EvalLimits,
}
impl EvalContext {
#[must_use]
pub fn new(
workspace_root: Arc<Path>,
environment: Option<Arc<str>>,
env_vars: EnvVarMode,
repo_vars: Map,
cascade_locals: Map,
funcs: Arc<FuncRegistry>,
limits: EvalLimits,
) -> Self {
Self {
workspace_root,
environment,
env_vars,
repo_vars,
cascade_locals,
funcs,
limits,
}
}
pub fn minimal(workspace_root: Arc<Path>) -> Result<Self, ValidationError> {
if workspace_root.as_os_str().is_empty() {
return Err(ValidationError::Empty {
field: "EvalContext.workspace_root",
});
}
Ok(Self {
workspace_root,
environment: None,
env_vars: EnvVarMode::default(),
repo_vars: Map::new(),
cascade_locals: Map::new(),
funcs: Arc::new(FuncRegistry::default()),
limits: EvalLimits::default(),
})
}
#[must_use]
pub fn lookup_repo_var(&self, name: &str) -> Option<&Value> {
self.repo_vars
.iter()
.find_map(|(k, v)| if k.as_ref() == name { Some(v) } else { None })
}
#[must_use]
pub fn lookup_cascade_local(&self, name: &str) -> Option<&Value> {
self.cascade_locals.iter().find_map(
|(k, v)| {
if k.as_ref() == name { Some(v) } else { None }
},
)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
#[test]
fn test_default_env_var_mode_is_strict_empty() {
let m = EnvVarMode::default();
assert!(!m.allows("HOME"));
assert!(!m.is_mock());
}
#[test]
fn test_strict_mode_allows_listed_names_only() {
let mut allowed = BTreeSet::new();
allowed.insert(Arc::<str>::from("TF_VAR_environment"));
let m = EnvVarMode::Strict { allowed };
assert!(m.allows("TF_VAR_environment"));
assert!(!m.allows("HOME"));
}
#[test]
fn test_passthrough_allows_everything() {
let m = EnvVarMode::Passthrough;
assert!(m.allows("ANY"));
}
#[test]
fn test_mock_rejects_all_and_is_mock() {
let m = EnvVarMode::Mock;
assert!(!m.allows("HOME"));
assert!(m.is_mock());
}
#[test]
fn test_eval_limits_defaults_match_spec() {
let l = EvalLimits::default();
assert_eq!(l.max_func_args, 64);
assert_eq!(l.max_str_size, 1 << 20);
assert_eq!(l.max_list_len, 100_000);
assert_eq!(l.max_iterations, 1_000_000);
}
#[test]
fn test_minimal_context_rejects_empty_root() {
let err = EvalContext::minimal(Arc::from(Path::new(""))).unwrap_err();
assert!(matches!(err, ValidationError::Empty { .. }));
}
#[test]
fn test_minimal_context_round_trips_vars() {
let mut ctx = EvalContext::minimal(Arc::from(Path::new("/tmp/repo"))).unwrap();
ctx.repo_vars
.push((Arc::from("region"), Value::Str(Arc::from("us-east-2"))));
assert_eq!(
ctx.lookup_repo_var("region"),
Some(&Value::Str(Arc::from("us-east-2")))
);
assert_eq!(ctx.lookup_repo_var("missing"), None);
}
#[test]
fn test_env_mode_serde_round_trip() {
let m = EnvVarMode::Strict {
allowed: [Arc::<str>::from("AWS_REGION")].into_iter().collect(),
};
let json = serde_json::to_string(&m).unwrap();
let back: EnvVarMode = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
}