use super::*;
use anyhow::{Context, Result};
use minijinja::Environment;
use rstest::rstest;
#[rstest]
#[case::targets("targets")]
#[case::actions("actions")]
fn expand_static_when_false_removes_entry_before_typed_ast(#[case] section: &str) -> Result<()> {
let env = Environment::new();
let yaml = format!(
"{section}:
- name: skipped
command: echo skipped
when: 'false'
- name: kept
command: echo kept"
);
let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?;
expand_foreach(&mut doc, &env)?;
let entries = section_entries(&doc, section)?;
anyhow::ensure!(entries.len() == 1, "expected one kept {section} entry");
let map = entries
.first()
.and_then(ManifestValue::as_object)
.with_context(|| format!("{section} entry map"))?;
let name = map
.get("name")
.and_then(ManifestValue::as_str)
.with_context(|| format!("{section} entry name"))?;
anyhow::ensure!(name == "kept", "unexpected kept {section} name: {name}");
anyhow::ensure!(
!map.contains_key("when"),
"when should be removed before typed AST deserialization"
);
Ok(())
}
#[rstest]
#[case::targets("targets")]
#[case::actions("actions")]
fn expand_foreach_when_injects_iteration_vars_only_for_kept_entries(
#[case] section: &str,
) -> Result<()> {
let env = Environment::new();
let yaml = format!(
"{section}:
- foreach:
- skip
- keep
- also-keep
when: item != 'skip'
name: '{{{{ item }}}}'
command: echo {{{{ item }}}}"
);
let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?;
expand_foreach(&mut doc, &env)?;
let entries = section_entries(&doc, section)?;
anyhow::ensure!(entries.len() == 2, "expected two kept {section} entries");
anyhow::ensure!(
indexes(entries, section)? == vec![1, 2],
"indexes should preserve original iteration positions"
);
let names: Result<Vec<_>> = entries
.iter()
.map(|entry| {
entry
.as_object()
.and_then(|map| map.get("name"))
.and_then(ManifestValue::as_str)
.map(str::to_owned)
.with_context(|| format!("{section} name"))
})
.collect();
let expected_names = vec!["{{ item }}".to_owned(), "{{ item }}".to_owned()];
anyhow::ensure!(
names? == expected_names,
"final string rendering should happen after expansion"
);
for entry in entries {
let map = entry
.as_object()
.with_context(|| format!("{section} entry map"))?;
anyhow::ensure!(
!map.contains_key("foreach"),
"foreach should be removed before typed AST deserialization"
);
anyhow::ensure!(
!map.contains_key("when"),
"when should be removed before typed AST deserialization"
);
}
Ok(())
}
#[test]
fn expand_static_when_can_read_entry_vars() -> Result<()> {
let env = Environment::new();
let yaml = "targets:
- name: kept
vars:
enabled: true
when: enabled
- name: skipped
vars:
enabled: false
when: enabled";
let mut doc: ManifestValue = serde_saphyr::from_str(yaml)?;
expand_foreach(&mut doc, &env)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.len() == 1, "expected one kept target");
let name = targets
.first()
.and_then(ManifestValue::as_object)
.and_then(|map| map.get("name"))
.and_then(ManifestValue::as_str)
.context("kept target name")?;
anyhow::ensure!(name == "kept", "unexpected kept target: {name}");
Ok(())
}
#[test]
fn expand_foreach_when_item_overrides_entry_vars() -> Result<()> {
let env = Environment::new();
let yaml = "targets:
- name: literal
foreach:
- keep
- skip
vars:
item: skip
when: item == 'keep'";
let mut doc: ManifestValue = serde_saphyr::from_str(yaml)?;
expand_foreach(&mut doc, &env)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.len() == 1, "expected one kept target");
anyhow::ensure!(indexes(targets, "target")? == vec![0], "wrong index");
Ok(())
}
#[test]
fn expand_foreach_expands_sequence_values() -> Result<()> {
let env = Environment::new();
let mut doc: ManifestValue = serde_saphyr::from_str(
"targets:
- name: literal
foreach:
- 1
- 2
vars:
static: keep",
)?;
expand_foreach(&mut doc, &env)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.len() == 2, "expected two targets");
for (idx, target) in targets.iter().enumerate() {
let map = target.as_object().context("target map")?;
let vars = map
.get("vars")
.and_then(|v| v.as_object())
.context("vars map")?;
let index_val = vars.get("index").context("index value")?;
let item_val = vars.get("item").context("item value")?;
let ManifestValue::Number(index_num) = index_val else {
anyhow::bail!("index should be numeric: {index_val:?}");
};
let index = index_num
.as_u64()
.context("numeric index conversion failed")?;
anyhow::ensure!(index == idx as u64, "unexpected index value: {index}");
let ManifestValue::Number(item_num) = item_val else {
anyhow::bail!("item should be numeric: {item_val:?}");
};
let item = item_num
.as_u64()
.context("numeric item conversion failed")?;
anyhow::ensure!(item == (idx + 1) as u64, "unexpected item value: {item}");
}
Ok(())
}
#[test]
fn expand_foreach_applies_when_expression() -> Result<()> {
let env = Environment::new();
let mut doc: ManifestValue = serde_saphyr::from_str(
"targets:
- name: literal
foreach: '[1, 2, 3]'
when: 'item > 1'",
)?;
expand_foreach(&mut doc, &env)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.len() == 2, "expected filtered targets");
let indexes = indexes(targets, "target")?;
anyhow::ensure!(
indexes == vec![1, 2],
"unexpected filtered indexes: {:?}",
indexes
);
Ok(())
}
#[test]
fn expand_foreach_preserves_object_key_order() -> Result<()> {
let env = Environment::new();
let yaml = r"targets:
- name: literal
vars:
existing: keep
foreach:
- 1
- 2
when: 'true'
after: done
";
let mut doc: ManifestValue = serde_saphyr::from_str(yaml)?;
expand_foreach(&mut doc, &env)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.len() == 2, "expected expanded targets");
for target in targets {
let map = target.as_object().context("target object")?;
let keys: Vec<&str> = map.keys().map(String::as_str).collect();
anyhow::ensure!(
keys == ["name", "vars", "after"],
"key order should remain stable: {:?}",
keys
);
}
Ok(())
}
#[rstest]
#[case("false", 0, "expression false drops target")]
#[case("0", 0, "expression 0 drops target")]
#[case("true", 1, "expression true keeps target")]
#[case("1 == 1", 1, "expression equality keeps target")]
#[case("{{ 0 }}", 0, "template 0 drops target")]
#[case("{{ 1 }}", 1, "template 1 keeps target")]
#[case("{{ \"true\" }}", 1, "template lowercase true keeps target")]
#[case("{{ \"True\" }}", 1, "template mixed case True keeps target")]
#[case("{{ \"TRUE\" }}", 1, "template uppercase TRUE keeps target")]
#[case("{{ 2 }}", 0, "template 2 drops target (only 1 is truthy)")]
#[case("{{ \"yes\" }}", 0, "template yes drops target (only true/1 truthy)")]
fn expand_static_target_when_evaluation(
#[case] when_expr: &str,
#[case] expected_count: usize,
#[case] description: &str,
) -> Result<()> {
let env = Environment::new();
let yaml = format!("targets:\n - name: target\n when: '{when_expr}'");
let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?;
expand_foreach(&mut doc, &env)?;
let targets = targets(&doc)?;
anyhow::ensure!(
targets.len() == expected_count,
"{description}: expected {expected_count} target(s), got {}",
targets.len()
);
if expected_count == 1 {
let target = targets.first().context("target")?;
let map = target.as_object().context("target object")?;
anyhow::ensure!(
!map.contains_key("when"),
"{description}: when field should be removed after evaluation"
);
}
Ok(())
}
#[rstest]
#[case("{{ unclosed", "malformed template")]
#[case("", "empty when expression")]
#[case(" ", "whitespace-only when expression")]
fn expand_static_target_when_invalid_errors(
#[case] when_expr: &str,
#[case] description: &str,
) -> Result<()> {
let env = Environment::new();
let yaml = format!("targets:\n - name: target\n when: '{when_expr}'");
let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?;
let result = expand_foreach(&mut doc, &env);
anyhow::ensure!(result.is_err(), "{description} should return Err");
Ok(())
}
#[rstest]
#[case::targets("targets")]
#[case::actions("actions")]
fn expand_foreach_invalid_expression_errors_during_template_expansion(
#[case] section: &str,
) -> Result<()> {
let env = Environment::new();
let yaml = format!("{section}:\n - name: bad\n foreach: '('");
let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?;
let result = expand_foreach(&mut doc, &env);
anyhow::ensure!(
result.is_err(),
"invalid foreach expression should fail for {section}"
);
Ok(())
}