use super::jinja_macros::{QueryEvaluation, evaluate_when_expression, render_when_template};
use super::{ManifestMap, ManifestValue};
use crate::hex::push_lower_hex_byte;
use crate::localization::{self, keys};
use anyhow::{Context, Result};
use minijinja::{Environment, context, value::Value};
use serde_json::{Number as JsonNumber, map::Entry};
use sha2::{Digest, Sha256};
const FILTERED_ENTRY_RETENTION_LIMIT: usize = 64;
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
pub(crate) struct FilteringStats {
pub filtered_targets: usize,
pub filtered_actions: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FilteredEntry {
pub section: String,
pub entry_name_hash: String,
pub iteration_index: Option<usize>,
pub when_expression_len: usize,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct ExpansionReport {
pub stats: FilteringStats,
pub filtered_entries: Vec<FilteredEntry>,
pub omitted_filtered_entries: usize,
}
impl ExpansionReport {
fn record_filtered_entry(&mut self, is_target: bool, filtered_entry: Option<FilteredEntry>) {
if is_target {
self.stats.filtered_targets += 1;
} else {
self.stats.filtered_actions += 1;
}
if let Some(retained_entry) = filtered_entry {
self.filtered_entries.push(retained_entry);
} else {
self.omitted_filtered_entries += 1;
}
}
const fn has_filtered_entry_capacity(&self) -> bool {
self.filtered_entries.len() < FILTERED_ENTRY_RETENTION_LIMIT
}
}
struct ExpansionContext<'a> {
env: &'a Environment<'a>,
section: &'a str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WhenEvaluation {
Include,
Exclude,
Conditional,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum WhenResolution {
Include,
Exclude(Option<FilteredEntry>),
Conditional,
}
pub(crate) fn expand_foreach(
doc: &mut ManifestValue,
env: &Environment,
) -> Result<ExpansionReport> {
let mut report = ExpansionReport::default();
expand_section(doc, "targets", env, &mut report)?;
expand_section(doc, "actions", env, &mut report)?;
Ok(report)
}
fn expand_section(
doc: &mut ManifestValue,
key: &str,
env: &Environment,
report: &mut ExpansionReport,
) -> Result<()> {
let Some(entries) = doc.get_mut(key).and_then(|v| v.as_array_mut()) else {
return Ok(());
};
let mut expanded = Vec::new();
let context = ExpansionContext { env, section: key };
for entry in std::mem::take(entries) {
match entry {
ManifestValue::Object(map) => {
expanded.extend(expand_target(map, &context, report)?);
}
other => expanded.push(other),
}
}
*entries = expanded;
Ok(())
}
fn expand_target(
mut map: ManifestMap,
context: &ExpansionContext<'_>,
report: &mut ExpansionReport,
) -> Result<Vec<ManifestValue>> {
if let Some(expr_val) = map.get("foreach") {
let values = parse_foreach_values(expr_val, context.env)?;
let mut items = Vec::new();
for (index, item) in values.into_iter().enumerate() {
let mut clone = map.clone();
clone.remove("foreach");
match when_allows(
&mut clone,
context,
Some((&item, index)),
report.has_filtered_entry_capacity(),
)? {
WhenResolution::Include => {}
WhenResolution::Exclude(event) => {
report.record_filtered_entry(context.section == "targets", event);
continue;
}
WhenResolution::Conditional => {
clone.insert("conditional".into(), ManifestValue::Bool(true));
}
}
inject_iteration_vars(&mut clone, &item, index)?;
items.push(ManifestValue::Object(clone));
}
Ok(items)
} else {
match when_allows(
&mut map,
context,
None,
report.has_filtered_entry_capacity(),
)? {
WhenResolution::Include => {}
WhenResolution::Exclude(event) => {
report.record_filtered_entry(context.section == "targets", event);
return Ok(vec![]);
}
WhenResolution::Conditional => {
map.insert("conditional".into(), ManifestValue::Bool(true));
}
}
Ok(vec![ManifestValue::Object(map)])
}
}
fn entry_name(map: &ManifestMap) -> &str {
map.get("name")
.and_then(ManifestValue::as_str)
.unwrap_or("<unnamed>")
}
fn entry_name_hash(entry_name: &str) -> String {
let digest = Sha256::digest(entry_name.as_bytes());
digest
.iter()
.take(4)
.fold(String::with_capacity(8), |mut hash, byte| {
push_lower_hex_byte(&mut hash, *byte);
hash
})
}
fn parse_foreach_values(expr_val: &ManifestValue, env: &Environment) -> Result<Vec<Value>> {
if let Some(seq) = expr_val.as_array() {
return Ok(seq.iter().cloned().map(Value::from_serialize).collect());
}
let expr = as_str(expr_val, "foreach")?;
let seq = eval_expression(env, "foreach", expr, context! {})?;
let iter = seq
.try_iter()
.context(localization::message(keys::MANIFEST_FOREACH_NOT_ITERABLE))?;
Ok(iter.collect())
}
fn eval_when(env: &Environment, expr: &str, ctx: &Value) -> Result<WhenEvaluation> {
anyhow::ensure!(
!expr.trim().is_empty(),
"{}",
localization::message(keys::MANIFEST_WHEN_EMPTY)
);
if let Some(evaluation) = evaluate_when_expression(env, expr, ctx)? {
return Ok(match evaluation {
QueryEvaluation::Value(is_true) => when_evaluation(is_true),
QueryEvaluation::QueryDisabled => WhenEvaluation::Conditional,
});
}
let rendered_template = match render_when_template(env, expr, ctx)? {
QueryEvaluation::Value(output) => output,
QueryEvaluation::QueryDisabled => return Ok(WhenEvaluation::Conditional),
};
Ok(when_evaluation(matches!(
rendered_template.trim().to_lowercase().as_str(),
"true" | "1"
)))
}
const fn when_evaluation(is_true: bool) -> WhenEvaluation {
if is_true {
WhenEvaluation::Include
} else {
WhenEvaluation::Exclude
}
}
fn when_allows(
map: &mut ManifestMap,
context: &ExpansionContext<'_>,
iteration: Option<(&Value, usize)>,
retain_filtered_entry: bool,
) -> Result<WhenResolution> {
let Some(when_val) = map.remove("when") else {
return Ok(WhenResolution::Include);
};
let expr = as_str(&when_val, "when")?;
let ctx = when_context(map, iteration)?;
match eval_when(context.env, expr, &ctx)? {
WhenEvaluation::Include => Ok(WhenResolution::Include),
WhenEvaluation::Conditional => Ok(WhenResolution::Conditional),
WhenEvaluation::Exclude => Ok(WhenResolution::Exclude(retain_filtered_entry.then(|| {
FilteredEntry {
section: context.section.to_owned(),
entry_name_hash: entry_name_hash(entry_name(map)),
iteration_index: iteration.map(|(_, index)| index),
when_expression_len: expr.len(),
}
}))),
}
}
fn when_context(map: &ManifestMap, iteration: Option<(&Value, usize)>) -> Result<Value> {
let mut vars = map
.get("vars")
.and_then(ManifestValue::as_object)
.cloned()
.unwrap_or_default();
if let Some((item, index)) = iteration {
vars.insert(
"item".into(),
serde_json::to_value(item)
.context(localization::message(keys::MANIFEST_FOREACH_SERIALISE_ITEM))?,
);
vars.insert(
"index".into(),
ManifestValue::Number(JsonNumber::from(index as u64)),
);
}
Ok(Value::from_serialize(vars))
}
fn inject_iteration_vars(map: &mut ManifestMap, item: &Value, index: usize) -> Result<()> {
let vars_value = match map.entry("vars") {
Entry::Vacant(slot) => slot.insert(ManifestValue::Object(ManifestMap::new())),
Entry::Occupied(slot) => {
let value = slot.into_mut();
match value {
ManifestValue::Object(_) => value,
other => {
return Err(anyhow::anyhow!(
"{}",
localization::message(keys::MANIFEST_TARGET_VARS_NOT_OBJECT)
.with_arg("value", format!("{other:?}"))
));
}
}
}
};
let vars = vars_value.as_object_mut().ok_or_else(|| {
anyhow::anyhow!(
"{}",
localization::message(keys::MANIFEST_VARS_ENTRY_NOT_OBJECT)
)
})?;
vars.insert(
"item".into(),
serde_json::to_value(item)
.context(localization::message(keys::MANIFEST_FOREACH_SERIALISE_ITEM))?,
);
let index_value = ManifestValue::Number(JsonNumber::from(index as u64));
vars.insert("index".into(), index_value);
Ok(())
}
fn as_str<'a>(value: &'a ManifestValue, field: &str) -> Result<&'a str> {
value.as_str().ok_or_else(|| {
anyhow::anyhow!(
"{}",
localization::message(keys::MANIFEST_FIELD_NOT_STRING).with_arg("field", field)
)
})
}
fn eval_expression(env: &Environment, name: &str, expr: &str, ctx: Value) -> Result<Value> {
env.compile_expression(expr)
.with_context(|| {
localization::message(keys::MANIFEST_EXPRESSION_PARSE_ERROR).with_arg("name", name)
})?
.eval(ctx)
.with_context(|| {
localization::message(keys::MANIFEST_EXPRESSION_EVAL_ERROR).with_arg("name", name)
})
}
#[cfg(test)]
#[path = "expand_tests.rs"]
mod tests;