aion-package 0.10.0

Archive validation, content hashing, and namespacing for Aion workflow packages.
Documentation
//! Generation of an `aion/testing` test skeleton per workflow (`aion generate`).
//!
//! A workflow is tested under the pure `aion/testing` harness (no engine, store,
//! or beamr): the author mocks each activity against its typed `Activity(i, o)`
//! value, advances a simulated clock for each durable timer, runs the typed
//! entry function, and asserts replay-determinism. That skeleton is mechanical —
//! one mock per declared activity, one clock advance per timer the workflow
//! arms, and the `testing.assert_replay` call — so `aion generate` emits it,
//! turning "test this workflow" from a blank file into a filled-in scaffold
//! (C29, S14).
//!
//! The skeleton targets the existing `aion/testing` harness only; it introduces
//! no test framework. It carries no default values (ADR-001): every author
//! decision the harness cannot derive — each mock's returned output and the
//! workflow's input — is a `todo` hole with a guiding label, so the scaffold
//! compiles as the structural shell it is and fails loudly the instant it runs
//! until the author fills the holes. Pre-filling those with invented defaults
//! would be a decision made for the author without telling them.

use std::fmt::Write as _;

use super::activity_model::ResolvedActivity;

/// Header every generated test module starts with: the do-not-overwrite contract
/// is softer than the byte-identical generated modules — this is a scaffold the
/// author edits — so the header says exactly that.
pub(crate) const GENERATED_TEST_HEADER: &str =
    "//// Generated by aion generate — a test scaffold to fill in, not a do-not-edit file.";

/// The facts the scaffold emitter needs about one workflow, beyond its resolved
/// activities: the entry module to import, the typed entry function to drive,
/// and the number of durable timers the workflow arms (sleeps + named timers
/// reachable from the entry function).
pub(crate) struct WorkflowTestFacts<'a> {
    /// The workflow's entry-module logical name (the module under test).
    pub(crate) entry_module: &'a str,
    /// The typed entry function the harness drives (the function passed to
    /// `workflow.define`, e.g. `execute`), which takes the typed input and
    /// returns the typed `Result(output, error)`.
    pub(crate) entry_function: &'a str,
    /// The number of distinct durable timers (`sleep` / `start_timer`) the
    /// workflow arms, reachable from the entry function — one clock advance is
    /// scaffolded per timer.
    pub(crate) timer_count: usize,
}

/// Emits the `test/<entry_module>_scaffold_test.gleam` skeleton for one
/// workflow.
///
/// The module imports `aion/testing`, the workflow's entry module, and the
/// generated wrappers/io modules, then defines one `gleam test` function that:
/// builds a fresh `TestEnv`, registers a typed mock for every declared activity
/// (`testing.mock_activity` against the generated `<name>_activity` wrapper),
/// advances the simulated clock once per durable timer, and asserts the typed
/// entry function replays deterministically (`testing.assert_replay`).
pub(crate) fn emit_scaffold_module(
    package_name: &str,
    facts: &WorkflowTestFacts<'_>,
    activities: &[ResolvedActivity<'_>],
) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "{GENERATED_TEST_HEADER}");
    out.push_str("////\n");
    let _ = writeln!(
        out,
        "//// A pure `aion/testing` skeleton for the `{}` workflow: each activity",
        facts.entry_module
    );
    out.push_str(
        "//// is pre-mocked, the simulated clock is advanced once per durable timer,\n\
         //// and the entry function is asserted to replay deterministically. Fill in\n\
         //// each `todo` (every mock's returned output and the workflow input) to make\n\
         //// the scenario concrete; the harness needs no engine, store, or beamr.\n",
    );
    out.push('\n');

    out.push_str("import aion/testing\n");
    out.push_str("import aion/duration\n");
    let _ = writeln!(out, "import {} as workflow_under_test", facts.entry_module);
    let _ = writeln!(out, "import {package_name}_activity_wrappers as wrappers");
    out.push_str("import gleeunit\n");

    out.push_str("\npub fn main() {\n  gleeunit.main()\n}\n");

    let _ = write!(
        out,
        "\n/// Replay-determinism scaffold for `{}.{}`. Every activity is mocked, the\n\
         /// clock is advanced once per durable timer, and the entry function is\n\
         /// asserted to emit an identical observable command sequence on replay.\n",
        facts.entry_module, facts.entry_function
    );
    out.push_str("pub fn replay_determinism_scaffold_test() {\n");
    out.push_str("  let assert Ok(env) = testing.new()\n\n");

    if activities.is_empty() {
        out.push_str("  // This workflow declares no activities, so there is nothing to mock.\n");
    } else {
        out.push_str("  // One typed mock per declared activity. Replace each `todo` with the\n");
        out.push_str("  // output the activity should return in this scenario.\n");
    }
    for activity in activities {
        emit_activity_mock(&mut out, activity);
    }

    if facts.timer_count == 0 {
        out.push_str(
            "\n  // This workflow arms no durable timers, so the clock is not advanced.\n",
        );
    } else {
        out.push_str(
            "\n  // One clock advance per durable timer this workflow arms. Replace each\n\
             \x20\x20// `todo` with the duration to advance past that timer's deadline.\n",
        );
        for index in 0..facts.timer_count {
            let _ = write!(
                out,
                "  let assert Ok(env) =\n    \
                 testing.advance(env, todo as \"duration past durable timer {}\")\n",
                index + 1
            );
        }
    }

    let _ = write!(
        out,
        "\n  // Drive the typed entry function and assert it replays deterministically.\n\
         \x20\x20// Replace the `todo` with the workflow input for this scenario.\n\
         \x20\x20let result =\n    \
         testing.assert_replay(env, fn() {{\n      \
         workflow_under_test.{}(todo as \"workflow input\")\n    \
         }})\n",
        facts.entry_function
    );
    out.push_str("  let assert Ok(_) = result\n");
    out.push_str("}\n");
    out
}

/// Emits one activity mock: a `testing.mock_activity` call binding the generated
/// `<name>_activity` wrapper to a `todo`-bodied handler whose returned output the
/// author fills in. The wrapper carries the input/output codecs, so the handler
/// is statically type-checked against the activity's real types.
fn emit_activity_mock(out: &mut String, activity: &ResolvedActivity<'_>) {
    let name = &activity.declaration.name;
    let _ = write!(
        out,
        "  let assert Ok(env) =\n    \
         testing.mock_activity(\n      \
         env,\n      \
         wrappers.{name}_activity(todo as \"input the {name} wrapper is built with\"),\n      \
         fn(_input) {{ todo as \"the {name} activity's mocked output\" }},\n    \
         )\n"
    );
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::{WorkflowTestFacts, emit_scaffold_module};
    use crate::codegen::activity_model::{ResolvedActivity, ResolvedType};
    use crate::codegen::declaration::{ActivityDeclaration, Tier};
    use crate::codegen::model::{BoundaryType, GleamType};

    fn artifact() -> BoundaryType {
        BoundaryType {
            file: PathBuf::from("schemas/placeholder.json"),
            stem: "placeholder".to_owned(),
            root: GleamType::Named {
                type_name: "Placeholder".to_owned(),
                fn_prefix: "placeholder".to_owned(),
            },
            defs: Vec::new(),
        }
    }

    fn declaration(name: &str) -> ActivityDeclaration {
        ActivityDeclaration {
            name: name.to_owned(),
            tier: Tier::InVm,
            input_type: "Order".to_owned(),
            output_type: "Receipt".to_owned(),
        }
    }

    fn resolved<'a>(
        declaration: &'a ActivityDeclaration,
        art: &'a BoundaryType,
    ) -> ResolvedActivity<'a> {
        ResolvedActivity {
            declaration,
            input: ResolvedType {
                gleam_type: declaration.input_type.clone(),
                fn_prefix: "order".to_owned(),
                boundary: art,
            },
            output: ResolvedType {
                gleam_type: declaration.output_type.clone(),
                fn_prefix: "receipt".to_owned(),
                boundary: art,
            },
        }
    }

    #[test]
    fn scaffold_mocks_each_activity_and_advances_per_timer() {
        let art = artifact();
        let reserve = declaration("reserve_inventory");
        let charge = declaration("charge_payment");
        let activities = [resolved(&reserve, &art), resolved(&charge, &art)];
        let facts = WorkflowTestFacts {
            entry_module: "order_saga",
            entry_function: "execute",
            timer_count: 2,
        };

        let module = emit_scaffold_module("demo", &facts, &activities);

        assert!(module.starts_with(super::GENERATED_TEST_HEADER));
        // Targets the existing harness — no new framework.
        assert!(module.contains("import aion/testing\n"));
        assert!(module.contains("import gleeunit\n"));
        assert!(module.contains("import order_saga as workflow_under_test\n"));
        assert!(module.contains("import demo_activity_wrappers as wrappers\n"));

        // One typed mock per declared activity, against its generated wrapper.
        assert_eq!(module.matches("testing.mock_activity(").count(), 2);
        assert!(module.contains("wrappers.reserve_inventory_activity("));
        assert!(module.contains("wrappers.charge_payment_activity("));

        // One clock advance per timer.
        assert_eq!(module.matches("testing.advance(").count(), 2);

        // The replay-determinism assertion drives the typed entry function.
        assert!(module.contains("testing.assert_replay(env, fn()"));
        assert!(module.contains("workflow_under_test.execute(todo"));

        // No invented defaults — every author hole is a labelled `todo` (ADR-001).
        assert!(module.contains("todo as \"workflow input\""));
        assert!(module.contains("todo as \"the reserve_inventory activity's mocked output\""));
    }

    #[test]
    fn scaffold_with_no_timers_does_not_advance_the_clock() {
        let art = artifact();
        let only = declaration("greet");
        let activities = [resolved(&only, &art)];
        let facts = WorkflowTestFacts {
            entry_module: "hello",
            entry_function: "execute",
            timer_count: 0,
        };

        let module = emit_scaffold_module("hello", &facts, &activities);

        assert_eq!(module.matches("testing.advance(").count(), 0);
        assert!(module.contains("arms no durable timers"));
    }

    #[test]
    fn scaffold_with_no_activities_mocks_nothing_but_still_asserts_replay() {
        let facts = WorkflowTestFacts {
            entry_module: "pure_flow",
            entry_function: "execute",
            timer_count: 1,
        };

        let module = emit_scaffold_module("pure_flow", &facts, &[]);

        assert_eq!(module.matches("testing.mock_activity(").count(), 0);
        assert!(module.contains("declares no activities"));
        assert_eq!(module.matches("testing.advance(").count(), 1);
        assert!(module.contains("testing.assert_replay(env, fn()"));
    }
}