use std::collections::HashMap;
use anyhow::Result;
use indexmap::IndexMap;
use crate::env::{expand_values, EnvMap};
use crate::frontmatter::Frontmatter;
#[derive(Debug, Clone)]
pub struct EnvSpec {
pub inherit_parent: bool,
pub overrides: EnvMap,
}
impl EnvSpec {
pub fn from_frontmatter(fm: &Frontmatter) -> Self {
Self {
inherit_parent: fm.agent_doc_env_inherit.unwrap_or(true),
overrides: fm.env.clone(),
}
}
#[allow(dead_code)] pub fn sealed(overrides: EnvMap) -> Self {
Self {
inherit_parent: false,
overrides,
}
}
pub fn resolve(&self) -> Result<HashMap<String, String>> {
let mut base: HashMap<String, String> = if self.inherit_parent {
std::env::vars().collect()
} else {
HashMap::new()
};
if self.overrides.is_empty() {
return Ok(base);
}
let expanded = expand_values(&self.overrides)?;
for (key, value) in expanded {
match value {
Some(v) => {
base.insert(key, v);
}
None => {
base.remove(&key);
}
}
}
Ok(base)
}
}
impl Default for EnvSpec {
fn default() -> Self {
Self {
inherit_parent: true,
overrides: IndexMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct EnvGuard {
key: &'static str,
prior: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let prior = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
Self { key, prior }
}
fn unset(key: &'static str) -> Self {
let prior = std::env::var(key).ok();
unsafe {
std::env::remove_var(key);
}
Self { key, prior }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.prior {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
fn env_map(entries: &[(&str, Option<&str>)]) -> EnvMap {
entries
.iter()
.map(|(k, v)| (k.to_string(), v.map(|s| s.to_string())))
.collect()
}
#[test]
fn from_frontmatter_default_inherits() {
let fm = Frontmatter::default();
let spec = EnvSpec::from_frontmatter(&fm);
assert!(spec.inherit_parent);
assert!(spec.overrides.is_empty());
}
#[test]
fn from_frontmatter_explicit_false_seals() {
let fm = Frontmatter {
agent_doc_env_inherit: Some(false),
..Default::default()
};
let spec = EnvSpec::from_frontmatter(&fm);
assert!(!spec.inherit_parent);
}
#[test]
fn from_frontmatter_copies_env_map_verbatim() {
let fm = Frontmatter {
env: env_map(&[("FOO", Some("bar")), ("BAZ", None)]),
..Default::default()
};
let spec = EnvSpec::from_frontmatter(&fm);
assert_eq!(spec.overrides.len(), 2);
assert_eq!(spec.overrides["FOO"], Some("bar".to_string()));
assert_eq!(spec.overrides["BAZ"], None);
}
#[test]
fn resolve_inherit_plus_overlay() {
let _g_keep = EnvGuard::set("AGENT_DOC_ENV_TEST_KEEP", "parent_value");
let _g_over = EnvGuard::set("AGENT_DOC_ENV_TEST_OVERRIDE", "parent");
let spec = EnvSpec {
inherit_parent: true,
overrides: env_map(&[("AGENT_DOC_ENV_TEST_OVERRIDE", Some("child"))]),
};
let resolved = spec.resolve().unwrap();
assert_eq!(
resolved.get("AGENT_DOC_ENV_TEST_KEEP"),
Some(&"parent_value".to_string()),
"inherited parent key survives overlay"
);
assert_eq!(
resolved.get("AGENT_DOC_ENV_TEST_OVERRIDE"),
Some(&"child".to_string()),
"overlay wins over parent"
);
}
#[test]
fn resolve_unset_removes_parent_key() {
let _g = EnvGuard::set("AGENT_DOC_ENV_TEST_DROP", "parent");
let spec = EnvSpec {
inherit_parent: true,
overrides: env_map(&[("AGENT_DOC_ENV_TEST_DROP", None)]),
};
let resolved = spec.resolve().unwrap();
assert!(
!resolved.contains_key("AGENT_DOC_ENV_TEST_DROP"),
"unset drops inherited parent key"
);
}
#[test]
fn resolve_expansion_uses_parent_env() {
let _g = EnvGuard::set("AGENT_DOC_ENV_TEST_BASE", "/parent/base");
let spec = EnvSpec {
inherit_parent: true,
overrides: env_map(&[(
"DERIVED",
Some("$AGENT_DOC_ENV_TEST_BASE/child"),
)]),
};
let resolved = spec.resolve().unwrap();
assert_eq!(
resolved.get("DERIVED"),
Some(&"/parent/base/child".to_string()),
"parent env is visible inside shell expansion"
);
}
#[test]
fn resolve_sealed_drops_parent() {
let _g = EnvGuard::set("AGENT_DOC_ENV_TEST_SEAL_WITNESS", "present");
let spec = EnvSpec {
inherit_parent: false,
overrides: env_map(&[("ONLY", Some("this"))]),
};
let resolved = spec.resolve().unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved.get("ONLY"), Some(&"this".to_string()));
assert!(
!resolved.contains_key("AGENT_DOC_ENV_TEST_SEAL_WITNESS"),
"sealed env ignores parent"
);
}
#[test]
fn resolve_is_deterministic_across_parent_mutation() {
let spec = EnvSpec {
inherit_parent: true,
overrides: env_map(&[("FIRST_RUN_KEY", Some("fixed"))]),
};
let resolved_once = spec.resolve().unwrap();
unsafe {
std::env::set_var("AGENT_DOC_ENV_TEST_POSTRESOLVE", "leaked");
}
let leaked_present = resolved_once.contains_key("AGENT_DOC_ENV_TEST_POSTRESOLVE");
unsafe {
std::env::remove_var("AGENT_DOC_ENV_TEST_POSTRESOLVE");
}
assert!(
!leaked_present,
"cached resolved map must not see post-resolve parent mutations"
);
assert_eq!(
resolved_once.get("FIRST_RUN_KEY"),
Some(&"fixed".to_string())
);
}
#[test]
fn resolve_empty_sealed_is_empty() {
let spec = EnvSpec::sealed(IndexMap::new());
let resolved = spec.resolve().unwrap();
assert!(resolved.is_empty());
}
}