use std::collections::BTreeSet;
use std::fmt::Write as _;
use crate::Package;
use super::error::StructureError;
use super::ident::{is_reserved_word, is_snake_identifier};
use super::model::{CorrelationKey, NodeId, NodePrimitive, WorkflowGraph};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StructuralDelta {
AppendRun {
activity: String,
after: NodeId,
},
RemoveNode {
id: NodeId,
},
}
pub fn regenerate_gleam(
package: &Package,
graph: &WorkflowGraph,
delta: &StructuralDelta,
) -> Result<String, StructureError> {
let declared: BTreeSet<&str> = package
.manifest()
.activities
.iter()
.map(|activity| activity.activity_type.as_str())
.collect();
let mut activities = run_chain(graph)?;
apply(&mut activities, graph, delta, &declared)?;
for activity in &activities {
validate_name(activity)?;
}
Ok(emit_module(&activities))
}
fn run_chain(graph: &WorkflowGraph) -> Result<Vec<String>, StructureError> {
let mut activities = Vec::with_capacity(graph.nodes.len());
for node in &graph.nodes {
match (&node.primitive, &node.correlation) {
(NodePrimitive::Run, CorrelationKey::ActivitySequence { activity, .. }) => {
activities.push(activity.clone());
}
_ => {
return Err(StructureError::UnboundedDelta {
reason: format!(
"node {} is a {:?}, but the bounded round-trip regenerates `run` chains \
only; regenerating arbitrary control flow is unbounded synthesis",
node.id.0, node.primitive
),
});
}
}
}
Ok(activities)
}
fn apply(
activities: &mut Vec<String>,
graph: &WorkflowGraph,
delta: &StructuralDelta,
declared: &BTreeSet<&str>,
) -> Result<(), StructureError> {
match delta {
StructuralDelta::AppendRun { activity, after } => {
if !declared.contains(activity.as_str()) {
return Err(StructureError::UnknownActivity {
activity: activity.clone(),
});
}
let position = node_position(graph, *after)?;
activities.insert(position + 1, activity.clone());
Ok(())
}
StructuralDelta::RemoveNode { id } => {
let position = node_position(graph, *id)?;
activities.remove(position);
Ok(())
}
}
}
fn node_position(graph: &WorkflowGraph, id: NodeId) -> Result<usize, StructureError> {
graph
.nodes
.iter()
.position(|node| node.id == id)
.ok_or(StructureError::DeltaTargetMissing { id: id.0 })
}
fn validate_name(activity: &str) -> Result<(), StructureError> {
if !is_snake_identifier(activity) {
return Err(StructureError::RegenInvalidName {
name: activity.to_owned(),
reason: "must be a snake_case identifier (a lowercase letter followed by lowercase \
letters, digits, or underscores)"
.to_owned(),
});
}
if is_reserved_word(activity) {
return Err(StructureError::RegenInvalidName {
name: activity.to_owned(),
reason: "is a Gleam reserved word and cannot name a generated function".to_owned(),
});
}
Ok(())
}
fn emit_module(activities: &[String]) -> String {
let mut out = String::new();
out.push_str(
"//// Regenerated by aion structure round-trip — a projection of the typed source.\n\
//// The typed module remains the single source of truth (ADR-014); this is for review.\n\n\
import aion/activity\n\
import aion/codec\n\
import aion/error\n\
import aion/workflow\n\n\
fn string_codec() -> codec.Codec(String) {\n\
\u{20}\u{20}codec.Codec(encode: fn(value) { value }, decode: fn(input) { Ok(input) })\n\
}\n\n",
);
let mut emitted: BTreeSet<&str> = BTreeSet::new();
for activity in activities {
if !emitted.insert(activity.as_str()) {
continue;
}
let _ = writeln!(
out,
"fn {activity}_activity(\n\
\u{20}\u{20}input: String,\n\
) -> activity.Activity(String, String) {{\n\
\u{20}\u{20}activity.new(\n\
\u{20}\u{20}\u{20}\u{20}\"{activity}\",\n\
\u{20}\u{20}\u{20}\u{20}input,\n\
\u{20}\u{20}\u{20}\u{20}string_codec(),\n\
\u{20}\u{20}\u{20}\u{20}string_codec(),\n\
\u{20}\u{20}\u{20}\u{20}fn(value) {{ Ok(value) }},\n\
\u{20}\u{20})\n\
}}\n"
);
}
out.push_str("pub fn execute(input: String) -> Result(String, error.ActivityError) {\n");
if activities.is_empty() {
out.push_str(" Ok(input)\n}\n");
return out;
}
emit_chain(&mut out, activities, 0);
out
}
fn emit_chain(out: &mut String, activities: &[String], index: usize) {
let indent = " ".repeat(index + 1);
let activity = &activities[index];
let value = if index == 0 {
"input".to_owned()
} else {
format!("value_{}", index - 1)
};
let _ = writeln!(
out,
"{indent}case workflow.run({activity}_activity({value})) {{"
);
let inner = " ".repeat(index + 2);
if index + 1 == activities.len() {
let _ = writeln!(out, "{inner}Ok(output) -> Ok(output)");
} else {
let _ = writeln!(out, "{inner}Ok(value_{index}) -> {{");
emit_chain(out, activities, index + 1);
let _ = writeln!(out, "{inner}}}");
}
let _ = writeln!(out, "{inner}Error(activity_error) -> Error(activity_error)");
let _ = writeln!(out, "{indent}}}");
if index == 0 {
out.push_str("}\n");
}
}