aion_package/structure/extract.rs
1//! Extract a workflow's primitive structure from its entry-module Gleam source.
2//!
3//! A Rust crate cannot type-check Gleam, so — following the codegen precedent
4//! (a generator operates over a canonical derived representation, never by
5//! type-checking Gleam) — extraction scans the entry-module source for the
6//! fixed `aion/workflow` primitive vocabulary. This is sound only because that
7//! vocabulary is small and known (ADR-014, P2). The supported subset is stated
8//! and enforced below; anything outside it is reported, never silently
9//! mis-parsed (no silent failures).
10//!
11//! ## Supported import/call subset
12//!
13//! - The entry module imports `aion/workflow`, optionally aliased
14//! (`import aion/workflow as <alias>`). Primitive calls are recognised as
15//! `<alias>.run`, `<alias>.all`, and so on. Absence of the import is a loud
16//! [`StructureError::NoWorkflowImport`], never an empty graph.
17//! - A `run` node's activity name is read from the
18//! `<wrappers_alias>.<name>_activity(` call passed to `run`, where the
19//! wrappers module is imported as `import <pkg>_activity_wrappers as <alias>`
20//! (the form `aion generate` emits). The extracted name is validated against
21//! the manifest's declared activities.
22//! - `start_timer`, `spawn`, and `spawn_and_wait` carry their first string
23//! literal argument as a name; it is read directly from source.
24//!
25//! ## Control flow
26//!
27//! Extraction is control-flow faithful (the [`super::control_flow`] walker). It
28//! starts at the manifest entry function, recurses the body in source order, and
29//! models a `case` over a workflow-primitive result as a [`NodePrimitive::Branch`]
30//! with labelled `Ok` / `Error` arm edges into the real success and compensation
31//! subgraphs — following local-helper calls so a saga's compensations land on
32//! the error arm, not in a false linear sequence. Control flow it cannot resolve
33//! is surfaced as an explicit [`NodePrimitive::Opaque`] node, never flattened.
34
35use crate::Package;
36
37use super::control_flow::ControlFlowExtractor;
38use super::error::StructureError;
39use super::model::WorkflowGraph;
40use super::scan::{Token, tokenise};
41
42/// Extracts the workflow graph model from a loaded package.
43///
44/// Reads the manifest entry module's verbatim Gleam source from
45/// [`Package::source`], walks it from the entry function over the
46/// `aion/workflow` primitive vocabulary, and builds a control-flow-faithful
47/// node/edge graph whose nodes carry the correlation key a consumer overlays a
48/// run's recorded events onto (C21, C22, C23).
49///
50/// # Errors
51///
52/// Returns [`StructureError::MissingEntrySource`] when the entry module has no
53/// source, [`StructureError::EntrySourceNotUtf8`] when its bytes are not UTF-8,
54/// [`StructureError::NoWorkflowImport`] when it does not import `aion/workflow`,
55/// [`StructureError::EntryFunctionNotFound`] when the manifest entry function is
56/// not defined in the source, and [`StructureError::UnknownActivity`] when a
57/// `run` node names an activity the manifest does not declare.
58pub fn extract_structure(package: &Package) -> Result<WorkflowGraph, StructureError> {
59 let entry_module = package.manifest().entry_module.clone();
60 let entry_function = package.manifest().entry_function.clone();
61 let bytes =
62 package
63 .source()
64 .get(&entry_module)
65 .ok_or_else(|| StructureError::MissingEntrySource {
66 module: entry_module.clone(),
67 })?;
68 let source = std::str::from_utf8(bytes).map_err(|_| StructureError::EntrySourceNotUtf8 {
69 module: entry_module.clone(),
70 })?;
71
72 let tokens = tokenise(source);
73 let workflow_alias =
74 workflow_alias(&tokens).ok_or_else(|| StructureError::NoWorkflowImport {
75 module: entry_module.clone(),
76 })?;
77
78 let declared: std::collections::BTreeSet<&str> = package
79 .manifest()
80 .activities
81 .iter()
82 .map(|activity| activity.activity_type.as_str())
83 .collect();
84
85 let extractor =
86 ControlFlowExtractor::new(entry_module.clone(), &tokens, workflow_alias, &declared);
87 let extracted = extractor.extract(&entry_function)?;
88
89 Ok(WorkflowGraph {
90 entry_module,
91 nodes: extracted.nodes,
92 edges: extracted.edges,
93 })
94}
95
96/// Finds the alias the entry module imports `aion/workflow` under.
97///
98/// `import aion/workflow` yields `workflow` (the last path segment); an explicit
99/// `import aion/workflow as <alias>` yields `<alias>`. Returns `None` when the
100/// module does not import `aion/workflow` at all.
101fn workflow_alias(tokens: &[Token]) -> Option<String> {
102 let mut index = 0;
103 while index < tokens.len() {
104 if matches!(&tokens[index], Token::Ident(word) if word == "import")
105 && import_path_is_workflow(tokens, index + 1)
106 {
107 if let Some(alias) = alias_after_import(tokens, index + 1) {
108 return Some(alias);
109 }
110 return Some("workflow".to_owned());
111 }
112 index += 1;
113 }
114 None
115}
116
117/// Whether the import path tokens beginning at `start` spell `aion/workflow`.
118///
119/// The scanner renders `aion/workflow` as `Ident("aion")`, `Other('/')`,
120/// `Ident("workflow")`; an aliased or selective import keeps the same path
121/// prefix, so matching the two path identifiers around the slash is sufficient
122/// and avoids matching `aion/workflow/timer` style submodule imports the entry
123/// module does not orchestrate through.
124fn import_path_is_workflow(tokens: &[Token], start: usize) -> bool {
125 matches!(
126 (tokens.get(start), tokens.get(start + 1), tokens.get(start + 2)),
127 (
128 Some(Token::Ident(first)),
129 Some(Token::Other('/')),
130 Some(Token::Ident(second)),
131 ) if first == "aion"
132 && second == "workflow"
133 && !matches!(tokens.get(start + 3), Some(Token::Other('/')))
134 )
135}
136
137/// Reads the alias of `import aion/workflow as <alias>`.
138///
139/// The path occupies three tokens (`aion`, `/`, `workflow`) from `path_start`;
140/// an alias, if present, is the token after a literal `as` immediately
141/// following the path. Anything else (a bare import, a selective
142/// `import aion/workflow.{...}`, or the next statement) yields no alias here and
143/// the caller falls back to the default `workflow`.
144fn alias_after_import(tokens: &[Token], path_start: usize) -> Option<String> {
145 let after_path = path_start + 3;
146 if matches!(tokens.get(after_path), Some(Token::Ident(word)) if word == "as") {
147 if let Some(Token::Ident(alias)) = tokens.get(after_path + 1) {
148 return Some(alias.clone());
149 }
150 }
151 None
152}