Skip to main content

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}