netsuke-build 0.1.0-beta2

A YAML-powered Ninja/Jinja hybrid build system.
//! Conditional expansion cases; action-only cases live in `action_condition_cases`.

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")?;
    // Iteration vars override colliding entry vars while unrelated vars survive.
    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<()> {
    // Name rendering happens in `render_manifest`, so drive the full parsing pipeline.
    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(())
}