bnto-core 0.18.2

Core WASM engine library for Bnto — shared types, traits, and orchestration
Documentation
// Scoped fields: a container's `fields` reach every step inside it, for
// params and `when` guards alike. The nearest declaration wins.
use super::always::{always_registry, run, started};
use super::item_templates::{item_registry, run_names};
use super::*;

/// A `fields` block declaring one string field `name` with `default`.
fn name_field(default: &str) -> String {
    format!(r#"{{ "name": {{ "type": "string", "label": "Name", "default": "{default}" }} }}"#)
}

/// An enum field `action` (start / stop) set to `value`.
fn action_field(value: &str) -> String {
    format!(
        r#"{{ "action": {{ "type": "enum", "label": "Action", "default": "{value}",
            "options": [{{ "value": "start", "label": "Start" }}, {{ "value": "stop", "label": "Stop" }}] }} }}"#
    )
}

/// A `test-name` step that names its file `{{fields.name}}`.
const NAME_STEP: &str =
    r#"{ "id": "name", "type": "test-name", "parameters": { "name": "{{fields.name}}" } }"#;

fn one_file() -> Vec<PipelineFile> {
    vec![make_file("a.txt", b"data")]
}

#[test]
fn a_step_reads_its_groups_field() {
    let json = format!(
        r#"{{ "nodes": [{{ "id": "g", "type": "group", "fields": {},
            "nodes": [{NAME_STEP}] }}] }}"#,
        name_field("from-group.txt")
    );
    assert_eq!(run_names(&json, one_file()), ["from-group.txt"]);
}

#[test]
fn a_step_reads_a_field_two_containers_up() {
    let json = format!(
        r#"{{ "nodes": [{{ "id": "g", "type": "group", "fields": {},
            "nodes": [{{ "id": "each", "type": "loop", "nodes": [{NAME_STEP}] }}] }}] }}"#,
        name_field("outer.txt")
    );
    assert_eq!(run_names(&json, one_file()), ["outer.txt"]);
}

#[test]
fn a_loops_field_reaches_every_iteration() {
    let json = format!(
        r#"{{ "nodes": [{{ "id": "each", "type": "loop", "fields": {},
            "nodes": [{NAME_STEP}] }}] }}"#,
        name_field("same.txt")
    );
    let files = vec![make_file("a.txt", b"1"), make_file("b.txt", b"2")];
    assert_eq!(run_names(&json, files), ["same.txt", "same.txt"]);
}

#[test]
fn a_steps_own_field_shadows_its_groups() {
    let json = format!(
        r#"{{ "nodes": [{{ "id": "g", "type": "group", "fields": {},
            "nodes": [{{ "id": "name", "type": "test-name", "fields": {},
                "parameters": {{ "name": "{{{{fields.name}}}}" }} }}] }}] }}"#,
        name_field("from-group.txt"),
        name_field("own.txt")
    );
    assert_eq!(run_names(&json, one_file()), ["own.txt"]);
}

#[test]
fn a_nearer_container_shadows_an_outer_one() {
    let json = format!(
        r#"{{ "nodes": [{{ "id": "outer", "type": "group", "fields": {},
            "nodes": [{{ "id": "inner", "type": "group", "fields": {},
                "nodes": [{NAME_STEP}] }}] }}] }}"#,
        name_field("outer.txt"),
        name_field("inner.txt")
    );
    assert_eq!(run_names(&json, one_file()), ["inner.txt"]);
}

#[test]
fn a_groups_field_stays_inside_the_group() {
    let json = format!(
        r#"{{ "nodes": [
            {{ "id": "g", "type": "group", "fields": {},
               "nodes": [{{ "id": "inside", "type": "test-echo" }}] }},
            {NAME_STEP}
        ] }}"#,
        name_field("leaked.txt")
    );
    // The step after the group can't see `name`, so the run is refused
    // rather than handing the step the literal placeholder (ISS-1).
    let def = parse_def(&json);
    let reporter = PipelineReporter::new_noop();
    let registry = item_registry();
    let error = execute_pipeline(
        &def,
        one_file(),
        &registry,
        &reporter,
        &NoopContext,
        fake_now,
    )
    .unwrap_err()
    .to_string();
    assert!(
        error.contains("Node 'name': {{fields.name}} names a field neither"),
        "{error}"
    );
}

/// A group declaring `action`, holding a `start` and a `stop` step guarded
/// on it: an if/else pair on one field.
fn if_else(action: &str) -> String {
    let guard = |expected| {
        format!(
            r#""when": {{ "value": "{{{{fields.action}}}}", "op": "equals", "expected": "{expected}" }}"#
        )
    };
    format!(
        r#"{{ "nodes": [{{ "id": "session", "type": "group", "fields": {},
            "nodes": [
                {{ "id": "start", "type": "test-capture", {} }},
                {{ "id": "stop", "type": "test-capture", {} }}
            ] }}] }}"#,
        action_field(action),
        guard("start"),
        guard("stop")
    )
}

#[test]
fn guards_inside_a_group_read_its_field() {
    for action in ["start", "stop"] {
        let (registry, _) = always_registry();
        let (result, events) = run(&if_else(action), &registry);
        assert!(result.is_ok(), "{result:?}");
        assert_eq!(started(&events), ["session", action], "action={action}");
    }
}

#[test]
fn a_groups_own_guard_reads_its_own_field() {
    let json = format!(
        r#"{{ "nodes": [{{ "id": "g", "type": "group", "fields": {},
            "when": {{ "value": "{{{{fields.action}}}}", "op": "equals", "expected": "start" }},
            "nodes": [{{ "id": "keep", "type": "test-capture" }}] }}] }}"#,
        action_field("stop")
    );
    let (registry, seen) = always_registry();
    let (result, events) = run(&json, &registry);
    assert!(result.is_ok(), "{result:?}");
    assert!(
        started(&events).is_empty(),
        "action is stop, so g is skipped"
    );
    assert!(seen.lock().unwrap().is_empty());
}

// --- Unfilled fields (ISS-1): refused before any step runs ---

/// A `test-capture` step `id` guarded on `{{fields.<field>}}`.
fn guarded_on(id: &str, field: &str) -> String {
    format!(
        r#"{{ "id": "{id}", "type": "test-capture",
            "when": {{ "value": "{{{{fields.{field}}}}}", "op": "equals", "expected": "x" }} }}"#
    )
}

/// The run's error and whether it emitted `PipelineStarted`.
fn refused(json: &str) -> (String, bool) {
    let (registry, seen) = always_registry();
    let (result, events) = run(json, &registry);
    assert!(seen.lock().unwrap().is_empty(), "no step may run");
    let started = events
        .iter()
        .any(|e| matches!(e, PipelineEvent::PipelineStarted { .. }));
    (result.expect_err("the run must fail").to_string(), started)
}

#[test]
fn a_field_declared_with_no_value_stops_the_run_before_it_starts() {
    let json = format!(
        r#"{{ "nodes": [{{ "id": "checks", "type": "group",
            "fields": {{ "version": {{ "type": "string", "label": "Version" }} }},
            "nodes": [{}] }}] }}"#,
        guarded_on("crates", "version")
    );
    let (error, started) = refused(&json);
    assert!(!started, "refused before PipelineStarted");
    assert!(error.contains("'crates'"), "{error}");
    assert!(error.contains("{{fields.version}}"), "{error}");
    assert!(
        error.contains("'checks'"),
        "names the declaring node: {error}"
    );
}

#[test]
fn a_field_no_node_in_scope_declares_stops_the_run_before_it_starts() {
    let json = format!(
        r#"{{ "nodes": [
            {{ "id": "write-config", "type": "test-capture", "fields": {} }},
            {} ] }}"#,
        name_field("x"),
        guarded_on("flatten", "name")
    );
    let (error, started) = refused(&json);
    assert!(!started, "refused before PipelineStarted");
    assert!(error.contains("'flatten'"), "{error}");
    assert!(error.contains("{{fields.name}}"), "{error}");
    assert!(error.contains("declares"), "{error}");
}