aion-nif 0.2.0

Native function declaration helpers for Gleam and Elixir Aion workflows.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
//! NIF declaration builders for deterministic and side-effectful paths.

use std::panic::{AssertUnwindSafe, catch_unwind};

use aion_core::{ActivityError, ActivityErrorKind};
use beamr::{
    native::{NativeFn, ProcessContext},
    scheduler::Scheduler,
    term::Term,
};

use crate::{FromTerm, IntoTerm, Nif, NifContext, TermError, into_term_via_payload};

/// Builds a deterministic NIF descriptor around a generated typed shim.
///
/// Determinism is fixed by this builder: it can only emit [`crate::Determinism::Pure`].
/// The [`deterministic_nif!`] macro is the public declaration surface. Its shim
/// checks arity, decodes each positional argument with [`FromTerm`], invokes the
/// typed author body, and encodes the return value with [`IntoTerm`]. It also
/// catches panics at the native boundary so a single buggy NIF cannot unwind
/// across beamr's scheduler/native FFI boundary.
#[must_use]
pub fn pure_descriptor(
    module: impl Into<String>,
    function: impl Into<String>,
    arity: u8,
    native: NativeFn,
) -> Nif {
    Nif::pure(module, function, arity, native)
}

/// Builds a side-effectful activity NIF descriptor around a generated typed shim.
///
/// Determinism is fixed by this builder: it can only emit
/// [`crate::Determinism::SideEffectful`]. Side-effectful NIFs are activity bodies, not
/// inline helpers. The engine invokes them through the activity contract so a
/// completed or failed result is recorded once and returned from history on
/// replay, never re-run as inline workflow code.
///
/// The descriptor is dirty by default because side effects may block, and a
/// blocking native function must not occupy a normal BEAM scheduler.
#[must_use]
pub fn activity_descriptor(
    module: impl Into<String>,
    function: impl Into<String>,
    arity: u8,
    native: NativeFn,
) -> Nif {
    Nif::side_effectful(module, function, arity, native)
}

/// Decodes one typed argument and annotates conversion failures with its index.
///
/// # Errors
///
/// Returns [`TermError::ArgumentDecode`] when `T` cannot be decoded from `term`.
#[doc(hidden)]
pub fn decode_argument<T>(term: Term, ctx: &ProcessContext, index: usize) -> Result<T, TermError>
where
    T: FromTerm,
{
    T::from_term(term, ctx).map_err(|source| TermError::ArgumentDecode {
        index,
        source: Box::new(source),
    })
}

/// Encodes an arity mismatch as a typed NIF error term.
#[doc(hidden)]
pub fn arity_error_term(expected: usize, actual: usize, ctx: &mut NifContext<'_, '_>) -> Term {
    let error = TermError::Conversion {
        context: "nif arity",
        message: format!("expected {expected} arguments, received {actual}"),
    };
    term_error_to_term(&error, ctx)
}

/// Encodes a conversion error as a typed deterministic NIF error term.
#[doc(hidden)]
pub fn term_error_to_term(error: &TermError, ctx: &mut NifContext<'_, '_>) -> Term {
    error_message_to_term(error.to_string(), ctx)
}

/// Encodes a conversion error as a terminal activity failure term.
#[doc(hidden)]
pub fn activity_term_error_to_term(error: &TermError, ctx: &mut NifContext<'_, '_>) -> Term {
    activity_error_to_term(term_error_activity(error), ctx)
}

/// Invokes a deterministic author body and converts success, conversion failure,
/// or panic into the beamr `NativeFn` result shape.
///
/// # Errors
///
/// Returns an error term when the body panics or the return value cannot be
/// encoded through [`IntoTerm`].
#[doc(hidden)]
pub fn invoke_pure<R, F>(ctx: &mut NifContext<'_, '_>, body: F) -> Result<Term, Term>
where
    R: IntoTerm,
    F: FnOnce() -> R,
{
    match catch_unwind(AssertUnwindSafe(body)) {
        Ok(value) => value
            .into_term(ctx)
            .map_err(|error| term_error_to_term(&error, ctx)),
        Err(payload) => Err(error_message_to_term(
            format!(
                "deterministic NIF body panicked: {}",
                panic_message(payload)
            ),
            ctx,
        )),
    }
}

/// Invokes a side-effectful activity body and converts its typed outcome into
/// the beamr `NativeFn` result shape.
///
/// # Errors
///
/// Returns a structured [`ActivityError`] term when the body reports an activity
/// failure, panics, or the success/error value cannot be encoded. Panic failures
/// are classified as [`ActivityErrorKind::Terminal`] so retry policy evaluation
/// can preserve the invariant that unwinding never crosses the FFI boundary.
#[doc(hidden)]
pub fn invoke_activity<R, F>(ctx: &mut NifContext<'_, '_>, body: F) -> Result<Term, Term>
where
    R: IntoTerm,
    F: FnOnce() -> Result<R, ActivityError>,
{
    match catch_unwind(AssertUnwindSafe(body)) {
        Ok(Ok(value)) => value
            .into_term(ctx)
            .map_err(|error| activity_error_to_term(term_error_activity(&error), ctx)),
        Ok(Err(error)) => Err(activity_error_to_term(error, ctx)),
        Err(payload) => Err(activity_error_to_term(
            ActivityError {
                kind: ActivityErrorKind::Terminal,
                message: format!("activity NIF body panicked: {}", panic_message(payload)),
                details: None,
            },
            ctx,
        )),
    }
}

/// Encodes an [`ActivityError`] as a JSON-shaped term that preserves kind,
/// message, and details for the engine's activity-failure reconstruction.
#[doc(hidden)]
pub fn activity_error_to_term(error: ActivityError, ctx: &mut NifContext<'_, '_>) -> Term {
    match into_term_via_payload(error, ctx) {
        Ok(term) => term,
        Err(error) => fallback_activity_error_term(error.to_string(), ctx),
    }
}

fn fallback_activity_error_term(message: String, ctx: &mut NifContext<'_, '_>) -> Term {
    let error = ActivityError {
        kind: ActivityErrorKind::Terminal,
        message,
        details: None,
    };
    match into_term_via_payload(error, ctx) {
        Ok(term) => term,
        Err(_) => ctx.process_mut().allocate_term(Term::NIL),
    }
}

fn term_error_activity(error: &TermError) -> ActivityError {
    ActivityError {
        kind: ActivityErrorKind::Terminal,
        message: error.to_string(),
        details: None,
    }
}

/// Handle used by a suspending activity NIF to wake the same BEAM process later.
///
/// This is a thin wrapper over beamr's existing `Scheduler::wake_with_result`;
/// it deliberately does not introduce a futures runtime, executor, or any other
/// async model.
pub struct ActivityWakeHandle<'scheduler> {
    scheduler: &'scheduler Scheduler,
    pid: u64,
}

impl ActivityWakeHandle<'_> {
    /// Wake the suspended process with `result` in the process' return register.
    pub fn wake_with_result(&self, result: Term) {
        self.scheduler.wake_with_result(self.pid, result);
    }

    /// Process id captured when the NIF requested suspension.
    #[must_use]
    pub const fn pid(&self) -> u64 {
        self.pid
    }
}

/// Requests beamr suspension for a side-effectful NIF that must wait instead of
/// blocking a dirty scheduler thread.
///
/// This mirrors the beamr-meridian async step pattern: capture the scheduler and
/// calling pid, call [`ProcessContext::request_suspend`], return from the NIF,
/// and later deliver the encoded result through [`Scheduler::wake_with_result`]
/// via the returned [`ActivityWakeHandle`]. This is the only async mechanism in
/// `aion-nif`; the crate does not define a futures/executor model.
///
/// # Errors
///
/// Returns [`TermError::Conversion`] when the process context does not carry a
/// pid, because beamr cannot wake a process without a target pid.
pub fn request_activity_suspend<'scheduler>(
    ctx: &mut ProcessContext,
    scheduler: &'scheduler Scheduler,
    timeout_ms: Option<u64>,
) -> Result<ActivityWakeHandle<'scheduler>, TermError> {
    let pid = ctx.pid().ok_or_else(|| TermError::Conversion {
        context: "activity suspend",
        message: "process context does not contain a pid".to_owned(),
    })?;
    ctx.request_suspend(timeout_ms);
    Ok(ActivityWakeHandle { scheduler, pid })
}

fn error_message_to_term(message: String, ctx: &mut NifContext<'_, '_>) -> Term {
    match Result::<String, String>::Err(message).into_term(ctx) {
        Ok(term) => term,
        Err(_) => ctx.process_mut().allocate_term(Term::NIL),
    }
}

fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
    match payload.downcast::<String>() {
        Ok(message) => *message,
        Err(payload) => match payload.downcast::<&'static str>() {
            Ok(message) => (*message).to_owned(),
            Err(_) => "unknown panic payload".to_owned(),
        },
    }
}

#[doc(hidden)]
#[macro_export]
macro_rules! __aion_nif_decode_argument {
    ($args:expr, $ctx:expr, $index:expr, $ty:ty) => {
        match $crate::declare::decode_argument::<$ty>($args[$index], $ctx.process(), $index) {
            Ok(value) => value,
            Err(error) => {
                return Err($crate::declare::term_error_to_term(&error, &mut $ctx));
            }
        }
    };
}

#[doc(hidden)]
#[macro_export]
macro_rules! __aion_nif_decode_activity_argument {
    ($args:expr, $ctx:expr, $index:expr, $ty:ty) => {
        match $crate::declare::decode_argument::<$ty>($args[$index], $ctx.process(), $index) {
            Ok(value) => value,
            Err(error) => {
                return Err($crate::declare::activity_term_error_to_term(
                    &error, &mut $ctx,
                ));
            }
        }
    };
}

/// Declares a pure deterministic NIF from a typed Rust body.
///
/// Determinism is a type-level declaration choice: this macro can only produce
/// [`crate::Determinism::Pure`]. Pure helpers may be bound inline
/// and re-executed during replay. Side-effectful work must use [`activity_nif!`],
/// which the engine invokes through the recorded activity contract and returns
/// from history on replay instead of re-running.
///
/// The generated shim never exposes raw `&[Term]` to author code. It checks
/// arity, decodes each argument by position, invokes the typed body, encodes the
/// typed return value through [`IntoTerm`], and catches panics before they can
/// unwind across the native boundary.
#[macro_export]
macro_rules! deterministic_nif {
    ($module:expr, $function:expr, $body:expr, () -> $ret:ty) => {
        $crate::deterministic_nif!($module, $function, || -> $ret { $body() })
    };
    ($module:expr, $function:expr, $body:expr, ($arg:ident : $arg_ty:ty) -> $ret:ty) => {
        $crate::deterministic_nif!($module, $function, |$arg: $arg_ty| -> $ret { $body($arg) })
    };
    (
        $module:expr,
        $function:expr,
        $body:expr,
        ($left:ident : $left_ty:ty, $right:ident : $right_ty:ty) -> $ret:ty
    ) => {
        $crate::deterministic_nif!($module, $function, |$left: $left_ty, $right: $right_ty| -> $ret {
            $body($left, $right)
        })
    };
    ($module:expr, $function:expr, || -> $ret:ty { $($body:tt)* }) => {{
        fn __aion_nif_shim(
            args: &[beamr::term::Term],
            ctx: &mut beamr::native::ProcessContext,
        ) -> Result<beamr::term::Term, beamr::term::Term> {
            let mut nif_ctx = $crate::NifContext::new(ctx);
            if args.len() != 0 {
                return Err($crate::declare::arity_error_term(0, args.len(), &mut nif_ctx));
            }
            $crate::declare::invoke_pure(&mut nif_ctx, || -> $ret { $($body)* })
        }

        $crate::declare::pure_descriptor($module, $function, 0, __aion_nif_shim)
    }};
    ($module:expr, $function:expr, |$arg:ident : $arg_ty:ty| -> $ret:ty { $($body:tt)* }) => {{
        fn __aion_nif_shim(
            args: &[beamr::term::Term],
            ctx: &mut beamr::native::ProcessContext,
        ) -> Result<beamr::term::Term, beamr::term::Term> {
            let mut nif_ctx = $crate::NifContext::new(ctx);
            if args.len() != 1 {
                return Err($crate::declare::arity_error_term(1, args.len(), &mut nif_ctx));
            }
            let $arg = $crate::__aion_nif_decode_argument!(args, nif_ctx, 0, $arg_ty);
            $crate::declare::invoke_pure(&mut nif_ctx, || -> $ret { $($body)* })
        }

        $crate::declare::pure_descriptor($module, $function, 1, __aion_nif_shim)
    }};
    (
        $module:expr,
        $function:expr,
        |$left:ident : $left_ty:ty, $right:ident : $right_ty:ty| -> $ret:ty { $($body:tt)* }
    ) => {{
        fn __aion_nif_shim(
            args: &[beamr::term::Term],
            ctx: &mut beamr::native::ProcessContext,
        ) -> Result<beamr::term::Term, beamr::term::Term> {
            let mut nif_ctx = $crate::NifContext::new(ctx);
            if args.len() != 2 {
                return Err($crate::declare::arity_error_term(2, args.len(), &mut nif_ctx));
            }
            let $left = $crate::__aion_nif_decode_argument!(args, nif_ctx, 0, $left_ty);
            let $right = $crate::__aion_nif_decode_argument!(args, nif_ctx, 1, $right_ty);
            $crate::declare::invoke_pure(&mut nif_ctx, || -> $ret { $($body)* })
        }

        $crate::declare::pure_descriptor($module, $function, 2, __aion_nif_shim)
    }};
}

/// Declares a side-effectful native activity NIF from a typed Rust body.
///
/// Determinism is a type-level declaration choice: this macro can only produce
/// [`crate::Determinism::SideEffectful`]. The body must return
/// `Result<T, aion_core::ActivityError>`, so retryable/terminal failure
/// classification is preserved. The engine must invoke these NIFs through the
/// recorded activity contract; replay returns history instead of re-running.
///
/// The generated shim mirrors [`crate::deterministic_nif!`]: it checks arity, decodes
/// each positional argument with [`FromTerm`], never exposes raw `&[Term]` to
/// author code, encodes `Ok(T)` as the return term, and encodes `Err(ActivityError)`
/// on the native error path.
#[macro_export]
macro_rules! activity_nif {
    ($module:expr, $function:expr, $body:expr, () -> $ret:ty) => {
        $crate::activity_nif!($module, $function, || -> $ret { $body() })
    };
    ($module:expr, $function:expr, $body:expr, ($arg:ident : $arg_ty:ty) -> $ret:ty) => {
        $crate::activity_nif!($module, $function, |$arg: $arg_ty| -> $ret { $body($arg) })
    };
    (
        $module:expr,
        $function:expr,
        $body:expr,
        ($left:ident : $left_ty:ty, $right:ident : $right_ty:ty) -> $ret:ty
    ) => {
        $crate::activity_nif!($module, $function, |$left: $left_ty, $right: $right_ty| -> $ret {
            $body($left, $right)
        })
    };
    ($module:expr, $function:expr, || -> $ret:ty { $($body:tt)* }) => {{
        fn __aion_nif_shim(
            args: &[beamr::term::Term],
            ctx: &mut beamr::native::ProcessContext,
        ) -> Result<beamr::term::Term, beamr::term::Term> {
            let mut nif_ctx = $crate::NifContext::new(ctx);
            if args.len() != 0 {
                return Err($crate::declare::activity_term_error_to_term(
                    &$crate::TermError::Conversion {
                        context: "nif arity",
                        message: format!("expected {} arguments, received {}", 0, args.len()),
                    },
                    &mut nif_ctx,
                ));
            }
            $crate::declare::invoke_activity(&mut nif_ctx, || -> $ret { $($body)* })
        }

        $crate::declare::activity_descriptor($module, $function, 0, __aion_nif_shim)
    }};
    ($module:expr, $function:expr, |$arg:ident : $arg_ty:ty| -> $ret:ty { $($body:tt)* }) => {{
        fn __aion_nif_shim(
            args: &[beamr::term::Term],
            ctx: &mut beamr::native::ProcessContext,
        ) -> Result<beamr::term::Term, beamr::term::Term> {
            let mut nif_ctx = $crate::NifContext::new(ctx);
            if args.len() != 1 {
                return Err($crate::declare::activity_term_error_to_term(
                    &$crate::TermError::Conversion {
                        context: "nif arity",
                        message: format!("expected {} arguments, received {}", 1, args.len()),
                    },
                    &mut nif_ctx,
                ));
            }
            let $arg = $crate::__aion_nif_decode_activity_argument!(args, nif_ctx, 0, $arg_ty);
            $crate::declare::invoke_activity(&mut nif_ctx, || -> $ret { $($body)* })
        }

        $crate::declare::activity_descriptor($module, $function, 1, __aion_nif_shim)
    }};
    (
        $module:expr,
        $function:expr,
        |$left:ident : $left_ty:ty, $right:ident : $right_ty:ty| -> $ret:ty { $($body:tt)* }
    ) => {{
        fn __aion_nif_shim(
            args: &[beamr::term::Term],
            ctx: &mut beamr::native::ProcessContext,
        ) -> Result<beamr::term::Term, beamr::term::Term> {
            let mut nif_ctx = $crate::NifContext::new(ctx);
            if args.len() != 2 {
                return Err($crate::declare::activity_term_error_to_term(
                    &$crate::TermError::Conversion {
                        context: "nif arity",
                        message: format!("expected {} arguments, received {}", 2, args.len()),
                    },
                    &mut nif_ctx,
                ));
            }
            let $left = $crate::__aion_nif_decode_activity_argument!(args, nif_ctx, 0, $left_ty);
            let $right = $crate::__aion_nif_decode_activity_argument!(args, nif_ctx, 1, $right_ty);
            $crate::declare::invoke_activity(&mut nif_ctx, || -> $ret { $($body)* })
        }

        $crate::declare::activity_descriptor($module, $function, 2, __aion_nif_shim)
    }};
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use aion_core::{ActivityError, ActivityErrorKind};
    use beamr::{
        atom::AtomTable,
        module::ModuleRegistry,
        native::{BifRegistryImpl, ProcessContext},
        scheduler::{Scheduler, SchedulerConfig},
        term::Term,
    };

    use crate::{
        Determinism, FromTerm, IntoTerm, TermError, from_term_via_payload, request_activity_suspend,
    };

    fn context() -> ProcessContext<'static> {
        let mut ctx = ProcessContext::new();
        ctx.set_atom_table(Some(Arc::new(AtomTable::with_common_atoms())));
        ctx
    }

    fn decode_error_term(term: Term, ctx: &ProcessContext) -> Result<String, TermError> {
        let result = Result::<String, String>::from_term(term, ctx)?;
        match result {
            Ok(message) | Err(message) => Ok(message),
        }
    }

    fn decode_activity_error(term: Term, ctx: &ProcessContext) -> Result<ActivityError, TermError> {
        from_term_via_payload(term, ctx)
    }

    fn scheduler() -> Result<Scheduler, String> {
        Scheduler::with_code_server(
            SchedulerConfig {
                thread_count: Some(1),
                ..Default::default()
            },
            Arc::new(ModuleRegistry::new()),
            Arc::new(AtomTable::with_common_atoms()),
            Arc::new(BifRegistryImpl::new()),
        )
    }

    #[test]
    fn deterministic_nif_declares_pure_helper_and_invokes_typed_body() -> Result<(), TermError> {
        let nif = deterministic_nif!("example/module", "concat", |left: String,
                                                                  right: String|
         -> String {
            format!("{left}{right}")
        });
        let mut ctx = context();
        let mut arg_ctx = context();
        let mut nif_ctx = crate::NifContext::new(&mut arg_ctx);
        let left = "hello ".to_owned().into_term(&mut nif_ctx)?;
        let right = "world".to_owned().into_term(&mut nif_ctx)?;

        let output =
            (nif.native())(&[left, right], &mut ctx).map_err(|term| TermError::Conversion {
                context: "test native invocation",
                message: format!("unexpected error term: {term:?}"),
            })?;

        assert_eq!(nif.determinism(), Determinism::Pure);
        assert!(!nif.is_dirty());
        assert_eq!(nif.arity(), 2);
        assert_eq!(String::from_term(output, &ctx)?, "hello world");
        Ok(())
    }

    #[test]
    fn deterministic_nif_reports_wrong_arity() -> Result<(), TermError> {
        let nif = deterministic_nif!("example/module", "concat", |left: String,
                                                                  right: String|
         -> String {
            format!("{left}{right}")
        });
        let mut ctx = context();
        let mut arg_ctx = context();
        let mut nif_ctx = crate::NifContext::new(&mut arg_ctx);
        let only = "hello".to_owned().into_term(&mut nif_ctx)?;

        let error = (nif.native())(&[only], &mut ctx)
            .err()
            .ok_or(TermError::HeapAllocation { shape: "test" })?;
        let message = decode_error_term(error, &ctx)?;

        assert!(message.contains("expected 2 arguments"));
        assert!(message.contains("received 1"));
        Ok(())
    }

    #[test]
    fn deterministic_nif_reports_decode_argument_index() -> Result<(), TermError> {
        let nif = deterministic_nif!("example/module", "concat", |left: String,
                                                                  right: String|
         -> String {
            format!("{left}{right}")
        });
        let mut ctx = context();
        let mut arg_ctx = context();
        let mut nif_ctx = crate::NifContext::new(&mut arg_ctx);
        let left = "hello".to_owned().into_term(&mut nif_ctx)?;
        let right = 42_i64.into_term(&mut nif_ctx)?;

        let error = (nif.native())(&[left, right], &mut ctx)
            .err()
            .ok_or(TermError::HeapAllocation { shape: "test" })?;
        let message = decode_error_term(error, &ctx)?;

        assert!(message.contains("failed to decode argument 1"));
        assert!(message.contains("utf8 binary") || message.contains("binary"));
        Ok(())
    }

    #[test]
    fn deterministic_nif_contains_author_panic() -> Result<(), TermError> {
        let nif = deterministic_nif!("example/module", "explode", || -> String { panic!("boom") });
        let mut ctx = context();

        let error = (nif.native())(&[], &mut ctx)
            .err()
            .ok_or(TermError::HeapAllocation { shape: "test" })?;
        let message = decode_error_term(error, &ctx)?;

        assert!(message.contains("panicked"));
        assert!(message.contains("boom"));
        Ok(())
    }

    #[test]
    fn activity_nif_declares_dirty_side_effectful_body_and_encodes_ok() -> Result<(), TermError> {
        let nif = activity_nif!("example/module", "read_env", |name: String| -> Result<
            String,
            ActivityError,
        > {
            Ok(format!("env:{name}"))
        });
        let mut ctx = context();
        let mut arg_ctx = context();
        let mut nif_ctx = crate::NifContext::new(&mut arg_ctx);
        let name = "AION_TEST_VALUE".to_owned().into_term(&mut nif_ctx)?;

        let output = (nif.native())(&[name], &mut ctx).map_err(|term| TermError::Conversion {
            context: "test activity invocation",
            message: format!("unexpected activity error term: {term:?}"),
        })?;

        assert_eq!(nif.determinism(), Determinism::SideEffectful);
        assert!(nif.is_dirty());
        assert_eq!(nif.arity(), 1);
        assert_eq!(String::from_term(output, &ctx)?, "env:AION_TEST_VALUE");
        Ok(())
    }

    fn retryable_activity_body() -> Result<String, ActivityError> {
        Err(ActivityError {
            kind: ActivityErrorKind::Retryable,
            message: "classified failure".to_owned(),
            details: None,
        })
    }

    #[test]
    fn activity_nif_encodes_retryable_and_terminal_activity_errors() -> Result<(), TermError> {
        let decode_failure =
            activity_nif!("example/module", "bad_decode", |name: String| -> Result<
                String,
                ActivityError,
            > { Ok(name) });
        let retryable = activity_nif!(
            "example/module", "fail", retryable_activity_body, () -> Result<String, ActivityError>
        );
        let terminal = activity_nif!(
            "example/module",
            "fail",
            || -> Result<String, ActivityError> {
                Err(ActivityError {
                    kind: ActivityErrorKind::Terminal,
                    message: "terminal failure".to_owned(),
                    details: None,
                })
            }
        );

        let mut ctx = context();
        let mut arg_ctx = context();
        let mut nif_ctx = crate::NifContext::new(&mut arg_ctx);
        let bad_arg = 42_i64.into_term(&mut nif_ctx)?;
        let decode_error = (decode_failure.native())(&[bad_arg], &mut ctx)
            .err()
            .ok_or(TermError::HeapAllocation { shape: "test" })?;
        let decoded_decode_error = decode_activity_error(decode_error, &ctx)?;

        let retryable_error = (retryable.native())(&[], &mut ctx)
            .err()
            .ok_or(TermError::HeapAllocation { shape: "test" })?;
        let decoded_retryable = decode_activity_error(retryable_error, &ctx)?;

        let terminal_error = (terminal.native())(&[], &mut ctx)
            .err()
            .ok_or(TermError::HeapAllocation { shape: "test" })?;
        let decoded_terminal = decode_activity_error(terminal_error, &ctx)?;

        assert_eq!(decoded_decode_error.kind, ActivityErrorKind::Terminal);
        assert!(
            decoded_decode_error
                .message
                .contains("failed to decode argument 0")
        );
        assert_eq!(decoded_retryable.kind, ActivityErrorKind::Retryable);
        assert!(decoded_retryable.is_retryable());
        assert_eq!(decoded_retryable.message, "classified failure");
        assert_eq!(decoded_terminal.kind, ActivityErrorKind::Terminal);
        assert!(!decoded_terminal.is_retryable());
        assert_eq!(decoded_terminal.message, "terminal failure");
        Ok(())
    }

    #[test]
    fn activity_nif_contains_panic_as_terminal_activity_error() -> Result<(), TermError> {
        let nif = activity_nif!(
            "example/module",
            "explode",
            || -> Result<String, ActivityError> { panic!("boom") }
        );
        let mut ctx = context();

        let error = (nif.native())(&[], &mut ctx)
            .err()
            .ok_or(TermError::HeapAllocation { shape: "test" })?;
        let decoded = decode_activity_error(error, &ctx)?;

        assert_eq!(decoded.kind, ActivityErrorKind::Terminal);
        assert!(decoded.message.contains("activity NIF body panicked"));
        assert!(decoded.message.contains("boom"));
        Ok(())
    }

    #[test]
    fn activity_suspend_helper_records_suspend_and_captures_wake_target() -> Result<(), TermError> {
        let scheduler = scheduler().map_err(|message| TermError::Conversion {
            context: "test scheduler",
            message,
        })?;
        let mut ctx = context();
        ctx.set_pid(Some(42));

        let handle = request_activity_suspend(&mut ctx, &scheduler, Some(250))?;
        let suspend = ctx
            .take_suspend()
            .ok_or(TermError::HeapAllocation { shape: "suspend" })?;

        assert_eq!(handle.pid(), 42);
        assert_eq!(suspend.timeout_ms, Some(250));
        let mut nif_ctx = crate::NifContext::new(&mut ctx);
        handle.wake_with_result("done".to_owned().into_term(&mut nif_ctx)?);
        scheduler.shutdown();
        Ok(())
    }
}