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                && let Some(Token::Ident(alias)) = tokens.get(after_path + 1)
119            {
120                return Some(alias.clone());
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            && left == alias
151            && right == "define"
152        {
153            let end = end_of_call(tokens, index, tokens.len());
154            return last_identifier_argument(tokens, index + 1, end)
155                .ok_or(FactsError::EntryFunctionNotIdentifiable);
156        }
157        index += 1;
158    }
159    Err(FactsError::NoDefineCall)
160}
161
162/// Counts the durable timers (`<alias>.sleep` / `<alias>.start_timer`) reachable
163/// from `function`, recursing into every reachable local helper exactly once.
164fn count_timers(
165    tokens: &[Token],
166    functions: &BTreeMap<String, FnBody>,
167    alias: &str,
168    function: &str,
169    visited: &mut BTreeSet<String>,
170) -> usize {
171    if !visited.insert(function.to_owned()) {
172        return 0;
173    }
174    let Some(body) = functions.get(function).copied() else {
175        return 0;
176    };
177    let mut count = 0;
178    let mut callees: Vec<String> = Vec::new();
179    let upper = body.end.min(tokens.len());
180    let mut index = body.start;
181    let mut depth: usize = 0;
182    while index < upper {
183        match &tokens[index] {
184            Token::OpenParen => depth += 1,
185            Token::CloseParen => depth = depth.saturating_sub(1),
186            Token::Qualified { left, right }
187                if left == alias && (right == "sleep" || right == "start_timer") =>
188            {
189                count += 1;
190            }
191            // Follow a helper both when applied directly (`name(`) and when
192            // passed as a bare function value in argument position
193            // (`list.map(items, name)`, depth >= 1), mirroring the determinism
194            // walk so the timer count does not undercount a timer reached only
195            // through a higher-order pass.
196            Token::Ident(name) if functions.contains_key(name) => {
197                let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
198                if applied || depth >= 1 {
199                    callees.push(name.clone());
200                }
201            }
202            _ => {}
203        }
204        index += 1;
205    }
206    for callee in callees {
207        count += count_timers(tokens, functions, alias, &callee, visited);
208    }
209    count
210}
211
212/// Maps every top-level `fn <name>(...) { <body> }` (with optional `pub`) to its
213/// body's token range, mirroring the extractor's function mapping.
214fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
215    let mut functions = BTreeMap::new();
216    let mut index = 0;
217    while index < tokens.len() {
218        if matches!(&tokens[index], Token::Ident(word) if word == "fn")
219            && let Some(Token::Ident(name)) = tokens.get(index + 1)
220            && let Some(open) = find_open_brace(tokens, index + 2, tokens.len())
221            && let Some(close) = match_brace(tokens, open, tokens.len())
222        {
223            functions.insert(
224                name.clone(),
225                FnBody {
226                    start: open + 1,
227                    end: close,
228                },
229            );
230            index = close + 1;
231            continue;
232        }
233        index += 1;
234    }
235    functions
236}
237
238#[cfg(test)]
239mod tests {
240    use super::{FactsError, extract_workflow_facts};
241
242    const SAGA: &str = "import aion/workflow\n\
243         pub fn definition() {\n  \
244         workflow.define(\"order\", a_codec(), b_codec(), c_codec(), execute)\n}\n\
245         pub fn execute(input) {\n  \
246         let _ = workflow.run(wrappers.charge_activity(input))\n  \
247         let _ = workflow.sleep(duration.seconds(1))\n  \
248         settle(input)\n}\n\
249         fn settle(input) {\n  \
250         workflow.start_timer(\"deadline\", duration.seconds(5))\n}\n";
251
252    #[test]
253    fn reads_typed_entry_and_counts_reachable_timers() -> Result<(), Box<dyn std::error::Error>> {
254        let facts = extract_workflow_facts(SAGA)?;
255        assert_eq!(facts.typed_entry_function, "execute");
256        // `workflow.sleep` in execute plus `workflow.start_timer` in the reachable
257        // `settle` helper: two durable timers.
258        assert_eq!(facts.timer_count, 2);
259        Ok(())
260    }
261
262    #[test]
263    fn unreachable_timer_is_not_counted() -> Result<(), Box<dyn std::error::Error>> {
264        let source = "import aion/workflow\n\
265             pub fn definition() {\n  \
266             workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
267             pub fn execute(input) {\n  \
268             workflow.run(wrappers.charge_activity(input))\n}\n\
269             fn dead(input) {\n  workflow.sleep(duration.seconds(1))\n}\n";
270        let facts = extract_workflow_facts(source)?;
271        assert_eq!(facts.timer_count, 0);
272        Ok(())
273    }
274
275    #[test]
276    fn timer_in_a_helper_passed_as_a_value_is_counted() -> Result<(), Box<dyn std::error::Error>> {
277        // `delayed` is never applied directly — it is passed as a bare function
278        // value to `list.map`, which invokes it. Counting its timer requires
279        // following the passed helper, the same soundness edge the determinism
280        // walk closes; a direct-call-only walk would undercount to zero.
281        let source = "import aion/workflow\n\
282             pub fn definition() {\n  \
283             workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
284             pub fn execute(input) {\n  \
285             let _ = list.map(input, delayed)\n  \
286             workflow.run(wrappers.charge_activity(input))\n}\n\
287             fn delayed(item) {\n  workflow.sleep(duration.seconds(1))\n}\n";
288        let facts = extract_workflow_facts(source)?;
289        assert_eq!(facts.typed_entry_function, "execute");
290        assert_eq!(facts.timer_count, 1);
291        Ok(())
292    }
293
294    #[test]
295    fn aliased_workflow_import_is_honoured() -> Result<(), Box<dyn std::error::Error>> {
296        let source = "import aion/workflow as wf\n\
297             pub fn definition() {\n  \
298             wf.define(\"f\", a(), b(), c(), execute)\n}\n\
299             pub fn execute(input) {\n  wf.sleep(duration.seconds(1))\n}\n";
300        let facts = extract_workflow_facts(source)?;
301        assert_eq!(facts.typed_entry_function, "execute");
302        assert_eq!(facts.timer_count, 1);
303        Ok(())
304    }
305
306    #[test]
307    fn missing_workflow_import_is_an_error() {
308        let source = "pub fn execute(input) {\n  Nil\n}\n";
309        assert_eq!(
310            extract_workflow_facts(source),
311            Err(FactsError::NoWorkflowImport)
312        );
313    }
314
315    #[test]
316    fn missing_define_call_is_an_error() {
317        let source = "import aion/workflow\n\
318             pub fn execute(input) {\n  workflow.run(wrappers.charge_activity(input))\n}\n";
319        assert_eq!(
320            extract_workflow_facts(source),
321            Err(FactsError::NoDefineCall)
322        );
323    }
324}