use super::*;
use anyhow::{Context, Result};
use minijinja::Environment;
use rstest::{fixture, rstest};
#[fixture]
fn environment() -> Environment<'static> {
Environment::new()
}
#[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");
ensure_foreach_removed(targets, "target")?;
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");
anyhow::ensure!(indexes(targets, "target")? == vec![1, 2], "wrong indexes");
ensure_foreach_removed(targets, "filtered target")?;
Ok(())
}
#[rstest]
fn expand_foreach_empty_foreach_produces_no_entries(
environment: Environment<'static>,
) -> Result<()> {
let mut doc: ManifestValue = serde_saphyr::from_str(
"targets:
- name: literal
foreach: []
command: echo hi",
)?;
expand_foreach(&mut doc, &environment)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.is_empty(), "empty foreach must produce no targets");
Ok(())
}
#[rstest]
fn expand_foreach_non_object_entry_is_passed_through(
environment: Environment<'static>,
) -> Result<()> {
let mut doc: ManifestValue = serde_saphyr::from_str(
"targets:
- just-a-string
- name: real
foreach:
- expanded
command: echo hi",
)?;
expand_foreach(&mut doc, &environment)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.len() == 2, "expected both entries to survive");
anyhow::ensure!(
targets.first().and_then(ManifestValue::as_str) == Some("just-a-string"),
"bare string entry should pass through unexpanded: {:?}",
targets.first()
);
let second_target = targets
.get(1)
.and_then(ManifestValue::as_object)
.context("second target object")?;
anyhow::ensure!(
second_target.get("name").and_then(ManifestValue::as_str) == Some("real"),
"second target should remain object named real: {second_target:?}"
);
anyhow::ensure!(
!second_target.contains_key("foreach"),
"expanded target should no longer contain foreach: {second_target:?}"
);
let vars = second_target
.get("vars")
.and_then(ManifestValue::as_object)
.context("second target vars")?;
anyhow::ensure!(
vars.get("item").and_then(ManifestValue::as_str) == Some("expanded"),
"second target should retain the iteration item: {vars:?}"
);
anyhow::ensure!(
vars.get("index").and_then(ManifestValue::as_u64) == Some(0),
"second target should retain the iteration index: {vars:?}"
);
Ok(())
}
#[rstest]
fn expand_foreach_iteration_vars_do_not_get_overwritten_by_entry_vars(
environment: Environment<'static>,
) -> Result<()> {
let mut doc: ManifestValue = serde_saphyr::from_str(
"targets:
- name: literal
foreach:
- from-iteration
vars:
item: from-entry
other: untouched",
)?;
expand_foreach(&mut doc, &environment)?;
let targets = targets(&doc)?;
anyhow::ensure!(targets.len() == 1, "expected one expanded target");
let vars = targets
.first()
.and_then(ManifestValue::as_object)
.and_then(|map| map.get("vars"))
.and_then(ManifestValue::as_object)
.context("vars map")?;
anyhow::ensure!(
vars.get("item").and_then(ManifestValue::as_str) == Some("from-iteration"),
"iteration item should override the entry's own item var: {vars:?}"
);
anyhow::ensure!(
vars.get("other").and_then(ManifestValue::as_str) == Some("untouched"),
"unrelated entry vars should survive expansion: {vars:?}"
);
Ok(())
}
#[test]
fn expand_foreach_jinja_filter_in_name() -> Result<()> {
let manifest = crate::manifest::from_str(
"netsuke_version: \"1.0.0\"
targets:
- name: '{{ item | upper }}'
foreach:
- alpha
- beta
command: echo hi",
)?;
let names: Vec<&str> = manifest
.targets
.iter()
.map(|t| match &t.name {
crate::ast::StringOrList::String(s) => Ok(s.as_str()),
other => Err(anyhow::anyhow!("expected string name, got {other:?}")),
})
.collect::<Result<_>>()?;
anyhow::ensure!(
names == ["ALPHA", "BETA"],
"expected uppercased names from Jinja filter: {names:?}"
);
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(())
}