Skip to main content

aion_package/structure/
facts.rs

1//! Lightweight workflow facts the test-scaffold generator needs from source.
2//!
3//! `aion generate` emits an `aion/testing` skeleton per workflow that drives the
4//! workflow's *typed* entry function and advances the simulated clock once per
5//! durable timer. Neither fact is in the package manifest: the manifest's
6//! `entry_function` is the engine-facing `run(raw_input: Dynamic)` adapter, while
7//! the harness drives the typed `execute` passed to `workflow.define`; and the
8//! timer count is a property of the workflow's control flow, not its
9//! declarations.
10//!
11//! This module reads both from the entry-module source, reusing the same
12//! [`super::scan`] tokeniser and the same function-mapping plus
13//! reachability-over-local-calls the extractor and the determinism analyser use,
14//! so all three agree on "reachable from workflow code". It is deliberately not a
15//! Gleam type-checker: the typed entry is read as the last identifier argument of
16//! the `workflow.define(...)` call, and timers are counted as the
17//! `<alias>.sleep` / `<alias>.start_timer` primitive calls reachable from that
18//! entry function — the same fixed vocabulary the graph extractor recognises.
19
20use std::collections::{BTreeMap, BTreeSet};
21
22use super::reader::{end_of_call, find_open_brace, last_identifier_argument, match_brace};
23use super::scan::{Token, tokenise};
24
25/// The facts the test-scaffold generator derives from a workflow's source.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct WorkflowFacts {
28    /// The typed entry function the test harness drives — the last argument of
29    /// the `workflow.define(...)` call (e.g. `execute`).
30    pub typed_entry_function: String,
31    /// The number of durable timers (`sleep` / `start_timer`) reachable from the
32    /// typed entry function — one clock advance is scaffolded per timer.
33    pub timer_count: usize,
34}
35
36/// Errors raised while reading workflow facts from source.
37#[derive(thiserror::Error, Debug, PartialEq, Eq)]
38pub enum FactsError {
39    /// The entry-module source never imports `aion/workflow`, so it composes
40    /// none of the recognised primitives and is not a workflow the scaffold
41    /// generator understands.
42    #[error(
43        "workflow source does not import `aion/workflow`; the test-scaffold generator only \
44         understands workflows that compose the `aion/workflow` primitive vocabulary"
45    )]
46    NoWorkflowImport,
47
48    /// No `workflow.define(...)` call was found, so the typed entry function the
49    /// harness must drive cannot be identified.
50    #[error(
51        "workflow source contains no `<alias>.define(...)` call; the typed entry function the \
52         test scaffold drives is the last argument of that call"
53    )]
54    NoDefineCall,
55
56    /// A `workflow.define(...)` call was found but its last argument is not a
57    /// bare function reference, so the typed entry function cannot be named.
58    #[error(
59        "the `<alias>.define(...)` call's last argument is not a bare entry-function reference; \
60         the test scaffold cannot identify the typed entry function to drive"
61    )]
62    EntryFunctionNotIdentifiable,
63}
64
65/// A function body as the half-open token range strictly inside its `{ }`.
66#[derive(Clone, Copy)]
67struct FnBody {
68    start: usize,
69    end: usize,
70}
71
72/// Reads the [`WorkflowFacts`] from a workflow's entry-module Gleam `source`.
73///
74/// # Errors
75///
76/// Returns [`FactsError::NoWorkflowImport`] when the source does not import
77/// `aion/workflow`, [`FactsError::NoDefineCall`] when no `<alias>.define(...)`
78/// call is present, and [`FactsError::EntryFunctionNotIdentifiable`] when that
79/// call's last argument is not a bare function reference.
80pub fn extract_workflow_facts(source: &str) -> Result<WorkflowFacts, FactsError> {
81    let tokens = tokenise(source);
82    let alias = workflow_alias(&tokens).ok_or(FactsError::NoWorkflowImport)?;
83    let typed_entry_function = define_entry_function(&tokens, &alias)?;
84
85    let functions = map_functions(&tokens);
86    let mut visited: BTreeSet<String> = BTreeSet::new();
87    let timer_count = if functions.contains_key(&typed_entry_function) {
88        count_timers(
89            &tokens,
90            &functions,
91            &alias,
92            &typed_entry_function,
93            &mut visited,
94        )
95    } else {
96        // The define call named a function the mapper did not find (e.g. an
97        // imported entry). With no body to walk, no timers are attributable;
98        // report zero rather than guessing.
99        0
100    };
101
102    Ok(WorkflowFacts {
103        typed_entry_function,
104        timer_count,
105    })
106}
107
108/// Finds the alias the source imports `aion/workflow` under (`workflow` by
109/// default, or the `as` alias), or `None` when it does not import it.
110fn workflow_alias(tokens: &[Token]) -> Option<String> {
111    let mut index = 0;
112    while index < tokens.len() {
113        if matches!(&tokens[index], Token::Ident(word) if word == "import")
114            && import_path_is_workflow(tokens, index + 1)
115        {
116            let after_path = index + 1 + 3;
117            if matches!(tokens.get(after_path), Some(Token::Ident(word)) if word == "as") {
118                if let Some(Token::Ident(alias)) = tokens.get(after_path + 1) {
119                    return Some(alias.clone());
120                }
121            }
122            return Some("workflow".to_owned());
123        }
124        index += 1;
125    }
126    None
127}
128
129/// Whether the import-path tokens beginning at `start` spell `aion/workflow`
130/// (and not a deeper submodule like `aion/workflow/timer`).
131fn import_path_is_workflow(tokens: &[Token], start: usize) -> bool {
132    matches!(
133        (tokens.get(start), tokens.get(start + 1), tokens.get(start + 2)),
134        (
135            Some(Token::Ident(first)),
136            Some(Token::Other('/')),
137            Some(Token::Ident(second)),
138        ) if first == "aion"
139            && second == "workflow"
140            && !matches!(tokens.get(start + 3), Some(Token::Other('/')))
141    )
142}
143
144/// Reads the typed entry function from the first `<alias>.define(...)` call: it
145/// is the last bare identifier argument before the call's closing paren.
146fn define_entry_function(tokens: &[Token], alias: &str) -> Result<String, FactsError> {
147    let mut index = 0;
148    while index < tokens.len() {
149        if let Token::Qualified { left, right } = &tokens[index] {
150            if left == alias && right == "define" {
151                let end = end_of_call(tokens, index, tokens.len());
152                return last_identifier_argument(tokens, index + 1, end)
153                    .ok_or(FactsError::EntryFunctionNotIdentifiable);
154            }
155        }
156        index += 1;
157    }
158    Err(FactsError::NoDefineCall)
159}
160
161/// Counts the durable timers (`<alias>.sleep` / `<alias>.start_timer`) reachable
162/// from `function`, recursing into every reachable local helper exactly once.
163fn count_timers(
164    tokens: &[Token],
165    functions: &BTreeMap<String, FnBody>,
166    alias: &str,
167    function: &str,
168    visited: &mut BTreeSet<String>,
169) -> usize {
170    if !visited.insert(function.to_owned()) {
171        return 0;
172    }
173    let Some(body) = functions.get(function).copied() else {
174        return 0;
175    };
176    let mut count = 0;
177    let mut callees: Vec<String> = Vec::new();
178    let upper = body.end.min(tokens.len());
179    let mut index = body.start;
180    let mut depth: usize = 0;
181    while index < upper {
182        match &tokens[index] {
183            Token::OpenParen => depth += 1,
184            Token::CloseParen => depth = depth.saturating_sub(1),
185            Token::Qualified { left, right }
186                if left == alias && (right == "sleep" || right == "start_timer") =>
187            {
188                count += 1;
189            }
190            // Follow a helper both when applied directly (`name(`) and when
191            // passed as a bare function value in argument position
192            // (`list.map(items, name)`, depth >= 1), mirroring the determinism
193            // walk so the timer count does not undercount a timer reached only
194            // through a higher-order pass.
195            Token::Ident(name) if functions.contains_key(name) => {
196                let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
197                if applied || depth >= 1 {
198                    callees.push(name.clone());
199                }
200            }
201            _ => {}
202        }
203        index += 1;
204    }
205    for callee in callees {
206        count += count_timers(tokens, functions, alias, &callee, visited);
207    }
208    count
209}
210
211/// Maps every top-level `fn <name>(...) { <body> }` (with optional `pub`) to its
212/// body's token range, mirroring the extractor's function mapping.
213fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
214    let mut functions = BTreeMap::new();
215    let mut index = 0;
216    while index < tokens.len() {
217        if matches!(&tokens[index], Token::Ident(word) if word == "fn") {
218            if let Some(Token::Ident(name)) = tokens.get(index + 1) {
219                if let Some(open) = find_open_brace(tokens, index + 2, tokens.len()) {
220                    if let Some(close) = match_brace(tokens, open, tokens.len()) {
221                        functions.insert(
222                            name.clone(),
223                            FnBody {
224                                start: open + 1,
225                                end: close,
226                            },
227                        );
228                        index = close + 1;
229                        continue;
230                    }
231                }
232            }
233        }
234        index += 1;
235    }
236    functions
237}
238
239#[cfg(test)]
240mod tests {
241    use super::{FactsError, extract_workflow_facts};
242
243    const SAGA: &str = "import aion/workflow\n\
244         pub fn definition() {\n  \
245         workflow.define(\"order\", a_codec(), b_codec(), c_codec(), execute)\n}\n\
246         pub fn execute(input) {\n  \
247         let _ = workflow.run(wrappers.charge_activity(input))\n  \
248         let _ = workflow.sleep(duration.seconds(1))\n  \
249         settle(input)\n}\n\
250         fn settle(input) {\n  \
251         workflow.start_timer(\"deadline\", duration.seconds(5))\n}\n";
252
253    #[test]
254    fn reads_typed_entry_and_counts_reachable_timers() -> Result<(), Box<dyn std::error::Error>> {
255        let facts = extract_workflow_facts(SAGA)?;
256        assert_eq!(facts.typed_entry_function, "execute");
257        // `workflow.sleep` in execute plus `workflow.start_timer` in the reachable
258        // `settle` helper: two durable timers.
259        assert_eq!(facts.timer_count, 2);
260        Ok(())
261    }
262
263    #[test]
264    fn unreachable_timer_is_not_counted() -> Result<(), Box<dyn std::error::Error>> {
265        let source = "import aion/workflow\n\
266             pub fn definition() {\n  \
267             workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
268             pub fn execute(input) {\n  \
269             workflow.run(wrappers.charge_activity(input))\n}\n\
270             fn dead(input) {\n  workflow.sleep(duration.seconds(1))\n}\n";
271        let facts = extract_workflow_facts(source)?;
272        assert_eq!(facts.timer_count, 0);
273        Ok(())
274    }
275
276    #[test]
277    fn timer_in_a_helper_passed_as_a_value_is_counted() -> Result<(), Box<dyn std::error::Error>> {
278        // `delayed` is never applied directly — it is passed as a bare function
279        // value to `list.map`, which invokes it. Counting its timer requires
280        // following the passed helper, the same soundness edge the determinism
281        // walk closes; a direct-call-only walk would undercount to zero.
282        let source = "import aion/workflow\n\
283             pub fn definition() {\n  \
284             workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
285             pub fn execute(input) {\n  \
286             let _ = list.map(input, delayed)\n  \
287             workflow.run(wrappers.charge_activity(input))\n}\n\
288             fn delayed(item) {\n  workflow.sleep(duration.seconds(1))\n}\n";
289        let facts = extract_workflow_facts(source)?;
290        assert_eq!(facts.typed_entry_function, "execute");
291        assert_eq!(facts.timer_count, 1);
292        Ok(())
293    }
294
295    #[test]
296    fn aliased_workflow_import_is_honoured() -> Result<(), Box<dyn std::error::Error>> {
297        let source = "import aion/workflow as wf\n\
298             pub fn definition() {\n  \
299             wf.define(\"f\", a(), b(), c(), execute)\n}\n\
300             pub fn execute(input) {\n  wf.sleep(duration.seconds(1))\n}\n";
301        let facts = extract_workflow_facts(source)?;
302        assert_eq!(facts.typed_entry_function, "execute");
303        assert_eq!(facts.timer_count, 1);
304        Ok(())
305    }
306
307    #[test]
308    fn missing_workflow_import_is_an_error() {
309        let source = "pub fn execute(input) {\n  Nil\n}\n";
310        assert_eq!(
311            extract_workflow_facts(source),
312            Err(FactsError::NoWorkflowImport)
313        );
314    }
315
316    #[test]
317    fn missing_define_call_is_an_error() {
318        let source = "import aion/workflow\n\
319             pub fn execute(input) {\n  workflow.run(wrappers.charge_activity(input))\n}\n";
320        assert_eq!(
321            extract_workflow_facts(source),
322            Err(FactsError::NoDefineCall)
323        );
324    }
325}