aion_package/structure/
regen.rs1use 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#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum StructuralDelta {
30 AppendRun {
35 activity: String,
37 after: NodeId,
39 },
40 RemoveNode {
42 id: NodeId,
44 },
45}
46
47pub 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
84fn 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
133fn 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
160fn 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 continue;
189 }
190 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
217fn 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}