use std::collections::{BTreeMap, BTreeSet, HashMap};
use lightshuttle_manifest::{InterpolationContext, Interpolator, Reference};
use crate::lifecycle::plan::LifecyclePlan;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvSource {
EnvFile,
Process,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvVarStatus {
Resolved(EnvSource),
Defaulted {
defaults: Vec<String>,
},
Missing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvVarReport {
pub name: String,
pub status: EnvVarStatus,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvReport {
pub vars: Vec<EnvVarReport>,
}
impl EnvReport {
#[must_use]
pub fn is_empty(&self) -> bool {
self.vars.is_empty()
}
#[must_use]
pub fn missing(&self) -> Vec<String> {
self.vars
.iter()
.filter(|v| v.status == EnvVarStatus::Missing)
.map(|v| v.name.clone())
.collect()
}
#[must_use]
pub fn has_missing(&self) -> bool {
self.vars.iter().any(|v| v.status == EnvVarStatus::Missing)
}
}
#[derive(Default)]
struct Aggregate {
required: bool,
defaults: BTreeSet<String>,
}
impl LifecyclePlan {
#[must_use]
pub fn env_report(&self, extra_env: &HashMap<String, String>) -> EnvReport {
let ctx = InterpolationContext::from_env()
.with_env(extra_env.iter().map(|(k, v)| (k.clone(), v.clone())));
let interpolator = Interpolator::new(&ctx);
let mut by_name: BTreeMap<String, Aggregate> = BTreeMap::new();
for node in self.nodes() {
for value in node.spec.env.values() {
collect_env_refs(&interpolator, value, &mut by_name);
}
if let Some(args) = &node.spec.command {
for arg in args {
collect_env_refs(&interpolator, arg, &mut by_name);
}
}
}
let vars = by_name
.into_iter()
.map(|(name, agg)| {
let status = classify(&interpolator, &name, &agg, extra_env);
EnvVarReport { name, status }
})
.collect();
EnvReport { vars }
}
}
fn collect_env_refs(
interpolator: &Interpolator<'_>,
value: &str,
by_name: &mut BTreeMap<String, Aggregate>,
) {
let Ok(refs) = interpolator.scan(value) else {
return;
};
for reference in refs {
if let Reference::Env { name, default } = reference {
let agg = by_name.entry(name).or_default();
match default {
None => agg.required = true,
Some(d) => {
agg.defaults.insert(d);
}
}
}
}
}
fn classify(
interpolator: &Interpolator<'_>,
name: &str,
agg: &Aggregate,
extra_env: &HashMap<String, String>,
) -> EnvVarStatus {
let probe = format!("${{env.{name}}}");
if interpolator.resolve(&probe).is_ok() {
let source = if extra_env.get(name).is_some_and(|v| !v.is_empty()) {
EnvSource::EnvFile
} else {
EnvSource::Process
};
EnvVarStatus::Resolved(source)
} else if agg.required {
EnvVarStatus::Missing
} else {
EnvVarStatus::Defaulted {
defaults: agg.defaults.iter().cloned().collect(),
}
}
}
#[cfg(test)]
mod tests {
use lightshuttle_manifest::Manifest;
use super::*;
fn plan_with_env(token: &str, level: &str) -> LifecyclePlan {
let yaml = format!(
"project:\n name: app\nresources:\n app:\n container:\n image: myapp:latest\n env:\n API_TOKEN: \"{token}\"\n LOG_LEVEL: \"{level}\"\n"
);
let manifest = Manifest::parse(&yaml).expect("valid manifest");
LifecyclePlan::from_manifest(&manifest).expect("valid plan")
}
fn plan_with_raw_env(env_block: &str) -> LifecyclePlan {
let yaml = format!(
"project:\n name: app\nresources:\n app:\n container:\n image: myapp:latest\n env:\n{env_block}"
);
let manifest = Manifest::parse(&yaml).expect("valid manifest");
LifecyclePlan::from_manifest(&manifest).expect("valid plan")
}
fn status_of<'a>(report: &'a EnvReport, name: &str) -> &'a EnvVarStatus {
&report
.vars
.iter()
.find(|v| v.name == name)
.expect("variable present")
.status
}
#[test]
fn env_file_value_resolves_with_env_file_source() {
let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
let mut env = HashMap::new();
env.insert("API_TOKEN".to_owned(), "secret".to_owned());
let report = plan.env_report(&env);
assert_eq!(
status_of(&report, "API_TOKEN"),
&EnvVarStatus::Resolved(EnvSource::EnvFile)
);
}
#[test]
fn unset_with_default_is_defaulted() {
let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
let mut env = HashMap::new();
env.insert("API_TOKEN".to_owned(), "secret".to_owned());
let report = plan.env_report(&env);
assert_eq!(
status_of(&report, "LOG_LEVEL"),
&EnvVarStatus::Defaulted {
defaults: vec!["info".to_owned()]
}
);
}
#[test]
fn empty_env_file_value_counts_as_missing() {
let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
let mut env = HashMap::new();
env.insert("API_TOKEN".to_owned(), String::new());
let report = plan.env_report(&env);
assert_eq!(status_of(&report, "API_TOKEN"), &EnvVarStatus::Missing);
assert!(report.has_missing());
assert_eq!(report.missing(), vec!["API_TOKEN".to_owned()]);
}
#[test]
fn divergent_defaults_are_all_reported_sorted() {
let plan = plan_with_raw_env(
" LOG_A: \"${env.LOG_LEVEL:-info}\"\n LOG_B: \"${env.LOG_LEVEL:-debug}\"\n",
);
let report = plan.env_report(&HashMap::new());
assert_eq!(
status_of(&report, "LOG_LEVEL"),
&EnvVarStatus::Defaulted {
defaults: vec!["debug".to_owned(), "info".to_owned()]
}
);
}
}