aion-package 0.8.0

Archive validation, content hashing, and namespacing for Aion workflow packages.
Documentation
//! Emission of the generated activity codec and wrapper Gleam modules.
//!
//! `aion generate` emits two Gleam modules from a package's declarations:
//!
//! - `src/<pkg>_codecs.gleam` — one `<prefix>_codec()` per distinct value type,
//!   wrapping the `<prefix>_to_json` / `<prefix>_decoder` functions `aion
//!   codegen` already generates from `schemas/*.json`, so the author's
//!   declarations and the generated wrappers share one codec per type.
//! - `src/<pkg>_activity_wrappers.gleam` — one `activity.Activity` constructor
//!   per declaration, pairing the engine activity name with its input/output
//!   codecs and the author-written body.
//!
//! Both are pure, deterministic functions of the resolved declarations
//! (declaration order preserved, value types emitted in type-name order), so a
//! delete-all-and-regenerate round-trip is byte-identical. Generated source is
//! never `gleam format`-checked — it carries the do-not-edit header and only
//! has to compile — so emission targets a fixed, simple layout rather than the
//! formatter's line-wrapping rules.

use std::collections::BTreeMap;
use std::fmt::Write as _;

use super::activity_model::ResolvedActivity;
use super::schema::{GleamType, SchemaArtifact};

/// Header line every generated activity module starts with (the do-not-edit
/// contract; `--check` regenerates and byte-compares against it).
pub(crate) const GENERATED_ACTIVITY_HEADER: &str =
    "//// Generated by aion generate — do not edit; regenerate from the activity declarations.";

/// Emits `src/<pkg>_codecs.gleam`: one `<prefix>_codec()` for each schema whose
/// root is a generated type (object or enum), in Gleam-type-name order.
///
/// The codecs are derived from the **schemas**, not the declarations, so this
/// module can be emitted *before* the package's `manifest()` is run to extract
/// the declarations — the author's activities module references
/// `codecs.<type>_codec()`, which must compile before extraction. Emitting from
/// schemas in both the pre-extraction phase and the final `aion generate` keeps
/// the output byte-identical (every activity value type is a named-root schema,
/// so every codec a wrapper needs is present).
pub(crate) fn emit_codecs_module(package_name: &str, schemas: &[SchemaArtifact]) -> String {
    // Named-root types keyed by Gleam type name → function prefix. `BTreeMap`
    // deduplicates and fixes a deterministic, type-name-ordered emission.
    let mut types: BTreeMap<&str, &str> = BTreeMap::new();
    for artifact in schemas {
        if let GleamType::Named {
            type_name,
            fn_prefix,
        } = &artifact.root
        {
            types.insert(type_name.as_str(), fn_prefix.as_str());
        }
    }

    let mut out = String::new();
    let _ = writeln!(out, "{GENERATED_ACTIVITY_HEADER}");
    out.push_str("////\n");
    let _ = writeln!(
        out,
        "//// Typed codecs for the `{package_name}` activity value types, wrapping the"
    );
    out.push_str("//// encoders and decoders generated from `schemas/*.json`.\n");
    out.push('\n');
    out.push_str("import aion/codec\n");
    let _ = writeln!(out, "import {package_name}_io as io");

    for (gleam_type, fn_prefix) in &types {
        let _ = write!(
            out,
            "\npub fn {fn_prefix}_codec() -> codec.Codec(io.{gleam_type}) {{\n  \
             codec.json_codec(io.{fn_prefix}_to_json, io.{fn_prefix}_decoder())\n}}\n"
        );
    }
    out
}

/// Emits `src/<pkg>_activity_wrappers.gleam`: one typed `<name>_activity`
/// constructor per declaration, in declaration order. Each pairs the engine
/// activity name with its codecs (from the generated codecs module) and the
/// author's body (from the hand-written activities module).
pub(crate) fn emit_wrappers_module(package_name: &str, activities: &[ResolvedActivity]) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "{GENERATED_ACTIVITY_HEADER}");
    out.push_str("////\n");
    let _ = writeln!(
        out,
        "//// Typed activity wrappers for the `{package_name}` workflow project, derived"
    );
    out.push_str("//// from the declarations its `manifest()` returns.\n");
    out.push('\n');
    out.push_str("import aion/activity\n");
    let _ = writeln!(out, "import {package_name}_activities as activities");
    let _ = writeln!(out, "import {package_name}_codecs as codecs");
    let _ = writeln!(out, "import {package_name}_io as io");

    for activity in activities {
        let name = &activity.declaration.name;
        let input_type = &activity.input.gleam_type;
        let output_type = &activity.output.gleam_type;
        let input_prefix = &activity.input.fn_prefix;
        let output_prefix = &activity.output.fn_prefix;
        let _ = write!(
            out,
            "\npub fn {name}_activity(\n  input: io.{input_type},\n\
             ) -> activity.Activity(io.{input_type}, io.{output_type}) {{\n  \
             activity.new(\n    \"{name}\",\n    input,\n    \
             codecs.{input_prefix}_codec(),\n    codecs.{output_prefix}_codec(),\n    \
             activities.{name},\n  )\n}}\n"
        );
    }
    out
}

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

    use super::{emit_codecs_module, emit_wrappers_module};
    use crate::codegen::activity_model::{ResolvedActivity, ResolvedType};
    use crate::codegen::declaration::{ActivityDeclaration, Tier};
    use crate::codegen::schema::{GleamType, SchemaArtifact};

    /// A throwaway artifact; the wrapper/codec emitters only read the borrowed
    /// reference's address, never its contents.
    fn artifact() -> SchemaArtifact {
        SchemaArtifact {
            file: PathBuf::from("schemas/placeholder.json"),
            stem: "placeholder".to_owned(),
            root: GleamType::String,
            defs: Vec::new(),
        }
    }

    fn declaration(name: &str, tier: Tier, input: &str, output: &str) -> ActivityDeclaration {
        ActivityDeclaration {
            name: name.to_owned(),
            tier,
            input_type: input.to_owned(),
            output_type: output.to_owned(),
        }
    }

    fn resolved<'a>(
        declaration: &'a ActivityDeclaration,
        art: &'a SchemaArtifact,
    ) -> ResolvedActivity<'a> {
        let prefix = |ty: &str| ty.to_lowercase();
        ResolvedActivity {
            declaration,
            input: ResolvedType {
                gleam_type: declaration.input_type.clone(),
                fn_prefix: prefix(&declaration.input_type),
                artifact: art,
            },
            output: ResolvedType {
                gleam_type: declaration.output_type.clone(),
                fn_prefix: prefix(&declaration.output_type),
                artifact: art,
            },
        }
    }

    /// A named-root schema artifact for `type_name` (objects/enums encode their
    /// codecs; only the root name and prefix matter to the codecs emitter).
    fn named_artifact(type_name: &str) -> SchemaArtifact {
        SchemaArtifact {
            file: PathBuf::from(format!("schemas/{}.json", type_name.to_lowercase())),
            stem: type_name.to_lowercase(),
            root: GleamType::Named {
                type_name: type_name.to_owned(),
                fn_prefix: type_name.to_lowercase(),
            },
            defs: Vec::new(),
        }
    }

    #[test]
    fn codecs_module_emits_one_codec_per_named_root_in_order() {
        // A scalar-root schema (e.g. a bare `string` payload) backs no activity
        // value type and gets no codec; only named roots do.
        let scalar = artifact();
        let schemas = [
            named_artifact("Order"),
            named_artifact("Receipt"),
            named_artifact("Shipment"),
            scalar,
        ];

        let module = emit_codecs_module("demo", &schemas);

        // Header + imports.
        assert!(module.starts_with(super::GENERATED_ACTIVITY_HEADER));
        assert!(module.contains("import aion/codec\n"));
        assert!(module.contains("import demo_io as io\n"));
        // Named roots only, ordered by type name: Order, Receipt, Shipment.
        let order = module.find("order_codec");
        let receipt = module.find("receipt_codec");
        let shipment = module.find("shipment_codec");
        assert!(order.is_some() && receipt.is_some() && shipment.is_some());
        assert!(order < receipt && receipt < shipment, "type-name order");
        assert_eq!(module.matches("order_codec").count(), 1, "one per type");
        // The scalar-root schema (stem `placeholder`) contributes no codec.
        assert!(!module.contains("placeholder_codec"));
        assert!(module.contains(
            "pub fn order_codec() -> codec.Codec(io.Order) {\n  \
             codec.json_codec(io.order_to_json, io.order_decoder())\n}\n"
        ));
    }

    #[test]
    fn wrappers_module_emits_one_constructor_per_declaration_in_order() {
        let art = artifact();
        let d1 = declaration("reserve_inventory", Tier::RemotePython, "Order", "Receipt");
        let d2 = declaration("ship_order", Tier::InVm, "Order", "Shipment");
        let activities = [resolved(&d1, &art), resolved(&d2, &art)];

        let module = emit_wrappers_module("demo", &activities);

        assert!(module.starts_with(super::GENERATED_ACTIVITY_HEADER));
        assert!(module.contains("import demo_activities as activities\n"));
        assert!(module.contains("import demo_codecs as codecs\n"));
        assert!(module.contains("import demo_io as io\n"));
        let first = module.find("reserve_inventory_activity");
        let second = module.find("ship_order_activity");
        assert!(first.is_some() && second.is_some());
        assert!(first < second, "declaration order preserved");
        assert!(module.contains(
            "pub fn reserve_inventory_activity(\n  input: io.Order,\n\
             ) -> activity.Activity(io.Order, io.Receipt) {\n  activity.new(\n    \
             \"reserve_inventory\",\n    input,\n    codecs.order_codec(),\n    \
             codecs.receipt_codec(),\n    activities.reserve_inventory,\n  )\n}\n"
        ));
    }
}