Skip to main content

aion_package/structure/
regen.rs

1//! Bounded structural round-trip: regenerate type-checking Gleam from a delta.
2//!
3//! The graph model is a projection, never the authoritative artifact (CN6,
4//! ADR-014). A consumer may apply one of a deliberately narrow set of
5//! structural deltas and ask for Gleam that still type-checks; the typed source
6//! remains the source of truth, and the returned String is for the consumer to
7//! review and write, not something this layer writes back into the package.
8//!
9//! The vocabulary is intentionally tiny — appending or removing a sequential
10//! `run` node — because unbounded diagram-to-code synthesis is explicitly out
11//! of scope (the boundary forbids it). A delta outside the set, or one naming an
12//! activity the package does not declare, is refused rather than emitting code.
13
14use std::collections::BTreeSet;
15use std::fmt::Write as _;
16
17use crate::Package;
18
19use super::error::StructureError;
20use super::ident::{is_reserved_word, is_snake_identifier};
21use super::model::{CorrelationKey, NodeId, NodePrimitive, WorkflowGraph};
22
23/// A bounded structural edit to a workflow graph.
24///
25/// The set is deliberately narrow (CN6): it is enough to prove the round-trip
26/// regenerates type-checking Gleam without becoming an unbounded second
27/// authoring surface.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum StructuralDelta {
30    /// Append a sequential `run` node for `activity` immediately after `after`.
31    ///
32    /// `activity` must be declared by the package manifest; an unknown activity
33    /// is refused (the canvas never invents an activity the source lacks).
34    AppendRun {
35        /// The activity the new `run` node dispatches.
36        activity: String,
37        /// The node the new node is sequenced after.
38        after: NodeId,
39    },
40    /// Remove the node with this id (and re-sequence around it).
41    RemoveNode {
42        /// The node to remove.
43        id: NodeId,
44    },
45}
46
47/// Applies a bounded structural delta to `graph` and regenerates a complete,
48/// self-contained Gleam workflow module that still type-checks against
49/// `aion_flow` (C24).
50///
51/// The regenerated module represents the graph's `run`-node chain over the
52/// package's declared activities. It is a projection: it does not mutate
53/// `package` or `graph`, and the typed source remains authoritative.
54///
55/// # Errors
56///
57/// Returns [`StructureError::UnboundedDelta`] when the delta is outside the
58/// bounded set (for example, the only node primitives the round-trip emits are
59/// `run` chains, so a graph carrying a non-`run` node cannot be regenerated),
60/// [`StructureError::DeltaTargetMissing`] when the delta targets an absent node,
61/// [`StructureError::UnknownActivity`] when an appended activity is not declared
62/// by the manifest, and [`StructureError::RegenInvalidName`] when a name would
63/// not be a valid Gleam identifier.
64pub fn regenerate_gleam(
65    package: &Package,
66    graph: &WorkflowGraph,
67    delta: &StructuralDelta,
68) -> Result<String, StructureError> {
69    let declared: BTreeSet<&str> = package
70        .manifest()
71        .activities
72        .iter()
73        .map(|activity| activity.activity_type.as_str())
74        .collect();
75
76    let mut activities = run_chain(graph)?;
77    apply(&mut activities, graph, delta, &declared)?;
78    for activity in &activities {
79        validate_name(activity)?;
80    }
81    Ok(emit_module(&activities))
82}
83
84/// Extracts the ordered list of activity names from a graph whose nodes are all
85/// sequential `run` nodes. Any non-`run` node makes the graph outside the
86/// bounded round-trip's emittable shape.
87fn run_chain(graph: &WorkflowGraph) -> Result<Vec<String>, StructureError> {
88    let mut activities = Vec::with_capacity(graph.nodes.len());
89    for node in &graph.nodes {
90        match (&node.primitive, &node.correlation) {
91            (NodePrimitive::Run, CorrelationKey::ActivitySequence { activity, .. }) => {
92                activities.push(activity.clone());
93            }
94            _ => {
95                return Err(StructureError::UnboundedDelta {
96                    reason: format!(
97                        "node {} is a {:?}, but the bounded round-trip regenerates `run` chains \
98                         only; regenerating arbitrary control flow is unbounded synthesis",
99                        node.id.0, node.primitive
100                    ),
101                });
102            }
103        }
104    }
105    Ok(activities)
106}
107
108fn apply(
109    activities: &mut Vec<String>,
110    graph: &WorkflowGraph,
111    delta: &StructuralDelta,
112    declared: &BTreeSet<&str>,
113) -> Result<(), StructureError> {
114    match delta {
115        StructuralDelta::AppendRun { activity, after } => {
116            if !declared.contains(activity.as_str()) {
117                return Err(StructureError::UnknownActivity {
118                    activity: activity.clone(),
119                });
120            }
121            let position = node_position(graph, *after)?;
122            activities.insert(position + 1, activity.clone());
123            Ok(())
124        }
125        StructuralDelta::RemoveNode { id } => {
126            let position = node_position(graph, *id)?;
127            activities.remove(position);
128            Ok(())
129        }
130    }
131}
132
133/// Resolves a node id to its index within the `run` chain.
134fn node_position(graph: &WorkflowGraph, id: NodeId) -> Result<usize, StructureError> {
135    graph
136        .nodes
137        .iter()
138        .position(|node| node.id == id)
139        .ok_or(StructureError::DeltaTargetMissing { id: id.0 })
140}
141
142fn validate_name(activity: &str) -> Result<(), StructureError> {
143    if !is_snake_identifier(activity) {
144        return Err(StructureError::RegenInvalidName {
145            name: activity.to_owned(),
146            reason: "must be a snake_case identifier (a lowercase letter followed by lowercase \
147                     letters, digits, or underscores)"
148                .to_owned(),
149        });
150    }
151    if is_reserved_word(activity) {
152        return Err(StructureError::RegenInvalidName {
153            name: activity.to_owned(),
154            reason: "is a Gleam reserved word and cannot name a generated function".to_owned(),
155        });
156    }
157    Ok(())
158}
159
160/// Emits a complete, self-contained Gleam workflow module for the `run` chain.
161///
162/// The module type-checks against `aion_flow` alone: each activity is a
163/// `String -> String` activity built with `activity.new`, and `execute` chains
164/// them with `workflow.run`, threading each result forward. There are no
165/// invented retry policies, timeouts, or other defaults (ADR-001); an activity
166/// built with `activity.new` carries none, which is the SDK's documented
167/// no-config form.
168fn emit_module(activities: &[String]) -> String {
169    let mut out = String::new();
170    out.push_str(
171        "//// Regenerated by aion structure round-trip — a projection of the typed source.\n\
172         //// The typed module remains the single source of truth (ADR-014); this is for review.\n\n\
173         import aion/activity\n\
174         import aion/codec\n\
175         import aion/error\n\
176         import aion/workflow\n\n\
177         fn string_codec() -> codec.Codec(String) {\n\
178         \u{20}\u{20}codec.Codec(encode: fn(value) { value }, decode: fn(input) { Ok(input) })\n\
179         }\n\n",
180    );
181
182    let mut emitted: BTreeSet<&str> = BTreeSet::new();
183    for activity in activities {
184        if !emitted.insert(activity.as_str()) {
185            // The same activity may appear at several positions in the chain;
186            // its wrapper function is defined once. Gleam module names must be
187            // unique, so a repeated dispatch reuses the one definition.
188            continue;
189        }
190        // Writing to a `String` is infallible; the discarded `Result` follows
191        // the codegen module's established `std::fmt::Write` emission pattern.
192        let _ = writeln!(
193            out,
194            "fn {activity}_activity(\n\
195             \u{20}\u{20}input: String,\n\
196             ) -> activity.Activity(String, String) {{\n\
197             \u{20}\u{20}activity.new(\n\
198             \u{20}\u{20}\u{20}\u{20}\"{activity}\",\n\
199             \u{20}\u{20}\u{20}\u{20}input,\n\
200             \u{20}\u{20}\u{20}\u{20}string_codec(),\n\
201             \u{20}\u{20}\u{20}\u{20}string_codec(),\n\
202             \u{20}\u{20}\u{20}\u{20}fn(value) {{ Ok(value) }},\n\
203             \u{20}\u{20})\n\
204             }}\n"
205        );
206    }
207
208    out.push_str("pub fn execute(input: String) -> Result(String, error.ActivityError) {\n");
209    if activities.is_empty() {
210        out.push_str("  Ok(input)\n}\n");
211        return out;
212    }
213    emit_chain(&mut out, activities, 0);
214    out
215}
216
217/// Emits the nested `case workflow.run(...)` chain that threads each activity's
218/// output into the next, returning the final output or the first error.
219///
220/// Writing to a `String` is infallible; discarded `Result`s follow the codegen
221/// module's established `std::fmt::Write` emission pattern.
222fn emit_chain(out: &mut String, activities: &[String], index: usize) {
223    let indent = "  ".repeat(index + 1);
224    let activity = &activities[index];
225    let value = if index == 0 {
226        "input".to_owned()
227    } else {
228        format!("value_{}", index - 1)
229    };
230    let _ = writeln!(
231        out,
232        "{indent}case workflow.run({activity}_activity({value})) {{"
233    );
234    let inner = "  ".repeat(index + 2);
235    if index + 1 == activities.len() {
236        let _ = writeln!(out, "{inner}Ok(output) -> Ok(output)");
237    } else {
238        let _ = writeln!(out, "{inner}Ok(value_{index}) -> {{");
239        emit_chain(out, activities, index + 1);
240        let _ = writeln!(out, "{inner}}}");
241    }
242    let _ = writeln!(out, "{inner}Error(activity_error) -> Error(activity_error)");
243    let _ = writeln!(out, "{indent}}}");
244    if index == 0 {
245        out.push_str("}\n");
246    }
247}