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