Skip to main content

aver/codegen/rust/
mod.rs

1/// Rust backend for the Aver transpiler.
2///
3/// Transforms Aver AST -> valid Rust source code.
4mod builtins;
5mod expr;
6mod liveness;
7mod pattern;
8mod policy;
9mod project;
10mod replay;
11mod runtime;
12mod syntax;
13mod toplevel;
14mod types;
15
16use std::collections::{BTreeMap, HashSet};
17
18use crate::ast::{FnDef, TopLevel, TypeDef};
19use crate::codegen::common::module_prefix_to_rust_segments;
20use crate::codegen::{CodegenContext, ProjectOutput};
21use crate::types::Type;
22
23#[derive(Default)]
24struct ModuleTreeNode {
25    content: Option<String>,
26    children: BTreeMap<String, ModuleTreeNode>,
27}
28
29/// Transpile an Aver program to a Rust project.
30pub fn transpile(ctx: &CodegenContext) -> ProjectOutput {
31    let used_services = detect_used_services(ctx);
32    let needs_http_types = needs_named_type(ctx, "Header")
33        || needs_named_type(ctx, "HttpResponse")
34        || needs_named_type(ctx, "HttpRequest");
35    let needs_tcp_types = needs_named_type(ctx, "Tcp.Connection");
36    let needs_terminal_types = needs_named_type(ctx, "Terminal.Size");
37
38    let has_tcp_runtime = used_services.contains("Tcp");
39    let has_http_runtime = used_services.contains("Http");
40    let has_http_server_runtime = used_services.contains("HttpServer");
41    let has_terminal_runtime = used_services.contains("Terminal");
42
43    let has_tcp_types = has_tcp_runtime || needs_tcp_types;
44    let has_http_types = has_http_runtime || has_http_server_runtime || needs_http_types;
45    let has_http_server_types = has_http_server_runtime || needs_named_type(ctx, "HttpRequest");
46    let has_terminal_types = has_terminal_runtime || needs_terminal_types;
47
48    let main_fn = ctx.fn_defs.iter().find(|fd| fd.name == "main");
49    let top_level_stmts: Vec<_> = ctx
50        .items
51        .iter()
52        .filter_map(|item| {
53            if let TopLevel::Stmt(stmt) = item {
54                Some(stmt)
55            } else {
56                None
57            }
58        })
59        .collect();
60    let verify_blocks: Vec<_> = ctx
61        .items
62        .iter()
63        .filter_map(|item| {
64            if let TopLevel::Verify(vb) = item {
65                Some(vb)
66            } else {
67                None
68            }
69        })
70        .collect();
71
72    let mut files = vec![
73        (
74            "Cargo.toml".to_string(),
75            project::generate_cargo_toml(
76                &ctx.project_name,
77                &used_services,
78                ctx.policy.is_some(),
79                ctx.emit_replay_runtime,
80                &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("aver-rt"),
81            ),
82        ),
83        (
84            "src/main.rs".to_string(),
85            render_root_main(
86                main_fn,
87                ctx.policy.is_some(),
88                ctx.emit_replay_runtime,
89                ctx.guest_entry.as_deref(),
90                !verify_blocks.is_empty(),
91            ),
92        ),
93        (
94            "src/runtime_support.rs".to_string(),
95            render_runtime_support(
96                has_tcp_types,
97                has_http_types,
98                has_http_server_types,
99                ctx.emit_replay_runtime,
100                ctx.emit_self_host_runtime,
101            ),
102        ),
103    ];
104
105    if let Some(config) = &ctx.policy {
106        files.push((
107            "src/policy_support.rs".to_string(),
108            format!("{}\n", policy::generate_policy_runtime(config)),
109        ));
110    }
111
112    if ctx.emit_replay_runtime {
113        files.push((
114            "src/replay_support.rs".to_string(),
115            replay::generate_replay_runtime(
116                ctx.policy.is_some(),
117                has_terminal_types,
118                has_tcp_types,
119                has_http_types,
120                has_http_server_types,
121            ),
122        ));
123    }
124
125    if !verify_blocks.is_empty() {
126        files.push((
127            "src/verify.rs".to_string(),
128            render_verify_module(&verify_blocks, ctx),
129        ));
130    }
131
132    let mut module_tree = ModuleTreeNode::default();
133    insert_module_content(
134        &mut module_tree,
135        &[String::from("entry")],
136        render_generated_module(
137            root_module_depends(&ctx.items),
138            entry_module_sections(ctx, main_fn, &top_level_stmts),
139        ),
140    );
141
142    for module in &ctx.modules {
143        let path = module_prefix_to_rust_segments(&module.prefix);
144        insert_module_content(
145            &mut module_tree,
146            &path,
147            render_generated_module(module.depends.clone(), module_sections(module, ctx)),
148        );
149    }
150
151    emit_module_tree_files(&module_tree, "src/aver_generated", &mut files);
152    files.sort_by(|left, right| left.0.cmp(&right.0));
153
154    ProjectOutput { files }
155}
156
157fn render_root_main(
158    main_fn: Option<&FnDef>,
159    has_policy: bool,
160    has_replay: bool,
161    guest_entry: Option<&str>,
162    has_verify: bool,
163) -> String {
164    let mut sections = vec![
165        "#![allow(unused_variables, unused_mut, dead_code, unused_imports, unused_parens, non_snake_case, non_camel_case_types, unreachable_patterns)]".to_string(),
166        "#[macro_use] extern crate aver_rt;".to_string(),
167        "pub use ::aver_rt::AverMap as HashMap;".to_string(),
168        "pub use ::aver_rt::AverStr;".to_string(),
169        String::new(),
170        "mod runtime_support;".to_string(),
171        "pub use runtime_support::*;".to_string(),
172    ];
173
174    if has_policy {
175        sections.push(String::new());
176        sections.push("mod policy_support;".to_string());
177        sections.push("pub use policy_support::*;".to_string());
178    }
179
180    if has_replay {
181        sections.push(String::new());
182        sections.push("mod replay_support;".to_string());
183        sections.push("pub use replay_support::*;".to_string());
184    }
185
186    sections.push(String::new());
187    sections.push("pub mod aver_generated;".to_string());
188
189    if has_verify {
190        sections.push(String::new());
191        sections.push("#[cfg(test)]".to_string());
192        sections.push("mod verify;".to_string());
193    }
194
195    // Spawn main on a thread with 256 MB stack to avoid overflow in deep recursion.
196    sections.push(String::new());
197    let returns_result = main_fn.is_some_and(|fd| fd.return_type.starts_with("Result<"));
198    let result_unit_string =
199        main_fn.is_some_and(|fd| fd.return_type.replace(' ', "") == "Result<Unit,String>");
200    if returns_result {
201        if result_unit_string {
202            sections.push("fn main() {".to_string());
203            sections.push("    let child = std::thread::Builder::new()".to_string());
204            sections.push("        .stack_size(256 * 1024 * 1024)".to_string());
205            if has_replay && guest_entry.is_none() {
206                sections.push("        .spawn(|| {".to_string());
207                sections.push("            let __result = aver_replay::with_guest_scope(\"main\", serde_json::Value::Null, aver_generated::entry::main);".to_string());
208                sections.push("            __result.map_err(|e| e.to_string())".to_string());
209                sections.push("        })".to_string());
210            } else {
211                sections.push("        .spawn(|| {".to_string());
212                sections
213                    .push("            let __result = aver_generated::entry::main();".to_string());
214                sections.push("            __result.map_err(|e| e.to_string())".to_string());
215                sections.push("        })".to_string());
216            }
217            sections.push("        .expect(\"thread spawn\");".to_string());
218            sections.push("    match child.join().expect(\"thread join\") {".to_string());
219            sections.push("        Ok(()) => {}".to_string());
220            sections.push("        Err(e) => {".to_string());
221            sections.push("            eprintln!(\"{}\", e);".to_string());
222            sections.push("            std::process::exit(1);".to_string());
223            sections.push("        }".to_string());
224            sections.push("    }".to_string());
225        } else {
226            let ret_type = types::type_annotation_to_rust(&main_fn.unwrap().return_type);
227            sections.push(format!("fn main() -> {} {{", ret_type));
228            if has_replay && guest_entry.is_none() {
229                sections.push(
230                    "    aver_replay::with_guest_scope(\"main\", serde_json::Value::Null, aver_generated::entry::main)"
231                        .to_string(),
232                );
233            } else {
234                sections.push("    aver_generated::entry::main()".to_string());
235            }
236        }
237    } else {
238        sections.push("fn main() {".to_string());
239        if main_fn.is_some() {
240            sections.push("    let child = std::thread::Builder::new()".to_string());
241            sections.push("        .stack_size(256 * 1024 * 1024)".to_string());
242            if has_replay && guest_entry.is_none() {
243                sections.push("        .spawn(|| aver_replay::with_guest_scope(\"main\", serde_json::Value::Null, || aver_generated::entry::main()))".to_string());
244            } else {
245                sections.push("        .spawn(|| aver_generated::entry::main())".to_string());
246            }
247            sections.push("        .expect(\"thread spawn\");".to_string());
248            sections.push("    child.join().expect(\"thread join\");".to_string());
249        }
250    }
251    sections.push("}".to_string());
252    sections.push(String::new());
253
254    sections.join("\n")
255}
256
257fn render_runtime_support(
258    has_tcp_types: bool,
259    has_http_types: bool,
260    has_http_server_types: bool,
261    has_replay: bool,
262    emit_self_host_runtime: bool,
263) -> String {
264    let mut sections = vec![runtime::generate_runtime(
265        has_replay,
266        has_http_server_types,
267        emit_self_host_runtime,
268    )];
269    if has_tcp_types {
270        sections.push(runtime::generate_tcp_types());
271    }
272    if has_http_types {
273        sections.push(runtime::generate_http_types());
274    }
275    if has_http_server_types {
276        sections.push(runtime::generate_http_server_types());
277    }
278    format!("{}\n", sections.join("\n\n"))
279}
280
281fn render_verify_module(
282    verify_blocks: &[&crate::ast::VerifyBlock],
283    ctx: &CodegenContext,
284) -> String {
285    [
286        "#[allow(unused_imports)]".to_string(),
287        "use crate::*;".to_string(),
288        "#[allow(unused_imports)]".to_string(),
289        "use crate::aver_generated::entry::*;".to_string(),
290        String::new(),
291        toplevel::emit_verify_blocks(verify_blocks, ctx),
292        String::new(),
293    ]
294    .join("\n")
295}
296
297fn render_generated_module(depends: Vec<String>, sections: Vec<String>) -> String {
298    if sections.is_empty() {
299        String::new()
300    } else {
301        let mut lines = vec![
302            "#[allow(unused_imports)]".to_string(),
303            "use crate::*;".to_string(),
304        ];
305        for dep in depends {
306            let path = module_prefix_to_rust_segments(&dep).join("::");
307            lines.push("#[allow(unused_imports)]".to_string());
308            lines.push(format!("use crate::aver_generated::{}::*;", path));
309        }
310        lines.push(String::new());
311        lines.push(sections.join("\n\n"));
312        lines.push(String::new());
313        lines.join("\n")
314    }
315}
316
317fn entry_module_sections(
318    ctx: &CodegenContext,
319    main_fn: Option<&FnDef>,
320    top_level_stmts: &[&crate::ast::Stmt],
321) -> Vec<String> {
322    let mut sections = Vec::new();
323
324    for td in &ctx.type_defs {
325        if is_shared_runtime_type(td) {
326            continue;
327        }
328        sections.push(toplevel::emit_public_type_def(td, ctx));
329        if ctx.emit_replay_runtime {
330            sections.push(replay::emit_replay_value_impl(td));
331        }
332    }
333
334    // Detect mutual TCO groups among non-main functions.
335    let non_main_fns: Vec<&FnDef> = ctx.fn_defs.iter().filter(|fd| fd.name != "main").collect();
336    let mutual_groups = toplevel::find_mutual_tco_groups(&non_main_fns);
337    let mut mutual_tco_members: HashSet<String> = HashSet::new();
338
339    for (group_id, group_indices) in mutual_groups.iter().enumerate() {
340        let group_fns: Vec<&FnDef> = group_indices.iter().map(|&idx| non_main_fns[idx]).collect();
341        for fd in &group_fns {
342            mutual_tco_members.insert(fd.name.clone());
343        }
344        sections.push(toplevel::emit_mutual_tco_block(
345            group_id + 1,
346            &group_fns,
347            ctx,
348            "pub ",
349        ));
350    }
351
352    for fd in &ctx.fn_defs {
353        if fd.name == "main" || mutual_tco_members.contains(&fd.name) {
354            continue;
355        }
356        let is_memo = ctx.memo_fns.contains(&fd.name);
357        sections.push(toplevel::emit_public_fn_def(fd, is_memo, ctx));
358    }
359
360    if main_fn.is_some() || !top_level_stmts.is_empty() {
361        sections.push(toplevel::emit_public_main(main_fn, top_level_stmts, ctx));
362    }
363
364    sections
365}
366
367fn module_sections(module: &crate::codegen::ModuleInfo, ctx: &CodegenContext) -> Vec<String> {
368    let mut sections = Vec::new();
369
370    for td in &module.type_defs {
371        if is_shared_runtime_type(td) {
372            continue;
373        }
374        sections.push(toplevel::emit_public_type_def(td, ctx));
375        if ctx.emit_replay_runtime {
376            sections.push(replay::emit_replay_value_impl(td));
377        }
378    }
379
380    // Detect mutual TCO groups among module functions.
381    let fn_refs: Vec<&FnDef> = module.fn_defs.iter().collect();
382    let mutual_groups = toplevel::find_mutual_tco_groups(&fn_refs);
383    let mut mutual_tco_members: HashSet<String> = HashSet::new();
384
385    for (group_id, group_indices) in mutual_groups.iter().enumerate() {
386        let group_fns: Vec<&FnDef> = group_indices.iter().map(|&idx| fn_refs[idx]).collect();
387        for fd in &group_fns {
388            mutual_tco_members.insert(fd.name.clone());
389        }
390        sections.push(toplevel::emit_mutual_tco_block(
391            group_id + 1,
392            &group_fns,
393            ctx,
394            "pub ",
395        ));
396    }
397
398    for fd in &module.fn_defs {
399        if mutual_tco_members.contains(&fd.name) {
400            continue;
401        }
402        let is_memo = ctx.memo_fns.contains(&fd.name);
403        sections.push(toplevel::emit_public_fn_def(fd, is_memo, ctx));
404    }
405
406    sections
407}
408
409fn insert_module_content(node: &mut ModuleTreeNode, segments: &[String], content: String) {
410    let child = node.children.entry(segments[0].clone()).or_default();
411    if segments.len() == 1 {
412        child.content = Some(content);
413    } else {
414        insert_module_content(child, &segments[1..], content);
415    }
416}
417
418fn emit_module_tree_files(node: &ModuleTreeNode, rel_dir: &str, files: &mut Vec<(String, String)>) {
419    let mut parts = Vec::new();
420
421    if let Some(content) = &node.content
422        && !content.trim().is_empty()
423    {
424        parts.push(content.trim_end().to_string());
425    }
426
427    for child_name in node.children.keys() {
428        parts.push(format!("pub mod {};", child_name));
429    }
430
431    let mut mod_rs = parts.join("\n\n");
432    if !mod_rs.is_empty() {
433        mod_rs.push('\n');
434    }
435    files.push((format!("{}/mod.rs", rel_dir), mod_rs));
436
437    for (child_name, child) in &node.children {
438        emit_module_tree_files(child, &format!("{}/{}", rel_dir, child_name), files);
439    }
440}
441
442fn root_module_depends(items: &[TopLevel]) -> Vec<String> {
443    items
444        .iter()
445        .find_map(|item| {
446            if let TopLevel::Module(module) = item {
447                Some(module.depends.clone())
448            } else {
449                None
450            }
451        })
452        .unwrap_or_default()
453}
454
455/// Detect which effectful services are used in the program (including modules).
456fn detect_used_services(ctx: &CodegenContext) -> HashSet<String> {
457    let mut services = HashSet::new();
458    for item in &ctx.items {
459        if let TopLevel::FnDef(fd) = item {
460            for eff in &fd.effects {
461                services.insert(eff.clone());
462                if let Some((service, _)) = eff.split_once('.') {
463                    services.insert(service.to_string());
464                }
465            }
466        }
467    }
468    for module in &ctx.modules {
469        for fd in &module.fn_defs {
470            for eff in &fd.effects {
471                services.insert(eff.clone());
472                if let Some((service, _)) = eff.split_once('.') {
473                    services.insert(service.to_string());
474                }
475            }
476        }
477    }
478    services
479}
480
481fn is_shared_runtime_type(td: &TypeDef) -> bool {
482    matches!(
483        td,
484        TypeDef::Product { name, .. }
485            if matches!(name.as_str(), "Header" | "HttpResponse" | "HttpRequest")
486    )
487}
488
489fn needs_named_type(ctx: &CodegenContext, wanted: &str) -> bool {
490    ctx.fn_sigs.values().any(|(params, ret, _effects)| {
491        params.iter().any(|p| type_contains_named(p, wanted)) || type_contains_named(ret, wanted)
492    })
493}
494
495fn type_contains_named(ty: &Type, wanted: &str) -> bool {
496    match ty {
497        Type::Named(name) => name == wanted,
498        Type::Result(ok, err) => {
499            type_contains_named(ok, wanted) || type_contains_named(err, wanted)
500        }
501        Type::Option(inner) | Type::List(inner) | Type::Vector(inner) => {
502            type_contains_named(inner, wanted)
503        }
504        Type::Tuple(items) => items.iter().any(|t| type_contains_named(t, wanted)),
505        Type::Map(k, v) => type_contains_named(k, wanted) || type_contains_named(v, wanted),
506        Type::Fn(params, ret, _effects) => {
507            params.iter().any(|t| type_contains_named(t, wanted))
508                || type_contains_named(ret, wanted)
509        }
510        Type::Int | Type::Float | Type::Str | Type::Bool | Type::Unit | Type::Unknown => false,
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::{
517        ModuleTreeNode, emit_module_tree_files, insert_module_content, render_generated_module,
518        transpile,
519    };
520    use crate::codegen::build_context;
521    use crate::source::parse_source;
522    use crate::tco;
523    use crate::types::checker::run_type_check_full;
524    use std::collections::HashSet;
525
526    fn ctx_from_source(source: &str, project_name: &str) -> crate::codegen::CodegenContext {
527        let mut items = parse_source(source).expect("source should parse");
528        tco::transform_program(&mut items);
529        let tc = run_type_check_full(&items, None);
530        assert!(
531            tc.errors.is_empty(),
532            "source should typecheck without errors: {:?}",
533            tc.errors
534        );
535        build_context(items, &tc, HashSet::new(), project_name.to_string(), vec![])
536    }
537
538    fn generated_rust_entry_file(out: &crate::codegen::ProjectOutput) -> &str {
539        out.files
540            .iter()
541            .find_map(|(name, content)| {
542                (name == "src/aver_generated/entry/mod.rs").then_some(content.as_str())
543            })
544            .expect("expected generated Rust entry module")
545    }
546
547    fn generated_file<'a>(out: &'a crate::codegen::ProjectOutput, path: &str) -> &'a str {
548        out.files
549            .iter()
550            .find_map(|(name, content)| (name == path).then_some(content.as_str()))
551            .unwrap_or_else(|| panic!("expected generated file '{}'", path))
552    }
553
554    #[test]
555    fn generated_module_imports_direct_depends() {
556        let rendered = render_generated_module(
557            vec!["Domain.Types".to_string(), "App.Commands".to_string()],
558            vec!["pub fn demo() {}".to_string()],
559        );
560
561        assert!(rendered.contains("use crate::aver_generated::domain::types::*;"));
562        assert!(rendered.contains("use crate::aver_generated::app::commands::*;"));
563        assert!(rendered.contains("pub fn demo() {}"));
564    }
565
566    #[test]
567    fn module_tree_files_do_not_reexport_children() {
568        let mut tree = ModuleTreeNode::default();
569        insert_module_content(
570            &mut tree,
571            &["app".to_string(), "cli".to_string()],
572            "pub fn run() {}".to_string(),
573        );
574
575        let mut files = Vec::new();
576        emit_module_tree_files(&tree, "src/aver_generated", &mut files);
577
578        let root_mod = files
579            .iter()
580            .find(|(path, _)| path == "src/aver_generated/mod.rs")
581            .map(|(_, content)| content)
582            .expect("root mod.rs should exist");
583
584        assert!(root_mod.contains("pub mod app;"));
585        assert!(!root_mod.contains("pub use app::*;"));
586    }
587
588    #[test]
589    fn list_cons_match_uses_cloned_uncons_fast_path() {
590        let ctx = ctx_from_source(
591            r#"
592module Demo
593
594fn headPlusTailLen(xs: List<Int>) -> Int
595    match xs
596        [] -> 0
597        [h, ..t] -> h + List.len(t)
598"#,
599            "demo",
600        );
601
602        let out = transpile(&ctx);
603        let entry = generated_rust_entry_file(&out);
604
605        // The common []/[h,..t] pattern uses aver_list_match! macro
606        assert!(entry.contains("aver_list_match!"));
607    }
608
609    #[test]
610    fn list_literal_clones_ident_when_used_afterward() {
611        let ctx = ctx_from_source(
612            r#"
613module Demo
614
615record Audit
616    message: String
617
618fn useTwice(audit: Audit) -> List<Audit>
619    first = [audit]
620    [audit]
621"#,
622            "demo",
623        );
624
625        let out = transpile(&ctx);
626        let entry = generated_rust_entry_file(&out);
627
628        assert!(entry.contains("let first = aver_rt::AverList::from_vec(vec![audit.clone()]);"));
629        assert!(entry.contains("aver_rt::AverList::from_vec(vec![audit])"));
630    }
631
632    #[test]
633    fn record_update_clones_base_when_value_is_used_afterward() {
634        let ctx = ctx_from_source(
635            r#"
636module Demo
637
638record PaymentState
639    paymentId: String
640    currency: String
641
642fn touch(state: PaymentState) -> String
643    updated = PaymentState.update(state, currency = "EUR")
644    state.paymentId
645"#,
646            "demo",
647        );
648
649        let out = transpile(&ctx);
650        let entry = generated_rust_entry_file(&out);
651
652        assert!(entry.contains("..state.clone()"));
653    }
654
655    #[test]
656    fn mutual_tco_generates_trampoline_instead_of_regular_calls() {
657        let ctx = ctx_from_source(
658            r#"
659module Demo
660
661fn isEven(n: Int) -> Bool
662    match n == 0
663        true -> true
664        false -> isOdd(n - 1)
665
666fn isOdd(n: Int) -> Bool
667    match n == 0
668        true -> false
669        false -> isEven(n - 1)
670"#,
671            "demo",
672        );
673
674        let out = transpile(&ctx);
675        let entry = generated_rust_entry_file(&out);
676
677        // Should generate trampoline enum and dispatch
678        assert!(entry.contains("enum __MutualTco1"));
679        assert!(entry.contains("fn __mutual_tco_trampoline_1"));
680        assert!(entry.contains("loop {"));
681
682        // Wrapper functions delegate to trampoline
683        assert!(entry.contains("pub fn isEven"));
684        assert!(entry.contains("pub fn isOdd"));
685        assert!(entry.contains("__mutual_tco_trampoline_1("));
686
687        // Should NOT contain direct recursive calls between the two
688        assert!(!entry.contains("isOdd((n - 1i64))"));
689    }
690
691    #[test]
692    fn field_access_does_not_double_clone() {
693        let ctx = ctx_from_source(
694            r#"
695module Demo
696
697record User
698    name: String
699    age: Int
700
701fn greet(u: User) -> String
702    u.name
703"#,
704            "demo",
705        );
706
707        let out = transpile(&ctx);
708        let entry = generated_rust_entry_file(&out);
709
710        // Field access should produce exactly one .clone(), never .clone().clone()
711        assert!(
712            !entry.contains(".clone().clone()"),
713            "double clone detected in generated code:\n{}",
714            entry
715        );
716    }
717
718    #[test]
719    fn single_field_variant_display_avoids_vec_join() {
720        let ctx = ctx_from_source(
721            r#"
722module Demo
723
724type Wrapper
725    Wrap(Int)
726    Pair(Int, Int)
727    Empty
728"#,
729            "demo",
730        );
731
732        let out = transpile(&ctx);
733        let entry = generated_rust_entry_file(&out);
734
735        // Single-field variant Wrap(Int): should NOT use vec![].join()
736        assert!(
737            !entry.contains("vec![f0.aver_display_inner()].join"),
738            "single-field variant should use direct format, not vec join:\n{}",
739            entry
740        );
741        // Multi-field variant Pair(Int, Int): SHOULD still use vec![].join()
742        assert!(
743            entry.contains("vec![f0.aver_display_inner(), f1.aver_display_inner()].join(\", \")"),
744            "multi-field variant should use vec join:\n{}",
745            entry
746        );
747    }
748
749    #[test]
750    fn replay_codegen_wraps_guest_entry_in_scoped_runtime() {
751        let mut ctx = ctx_from_source(
752            r#"
753module Demo
754
755fn runGuestProgram(path: String) -> Result<String, String>
756    ! [Disk.readText]
757    Disk.readText(path)
758"#,
759            "demo",
760        );
761        ctx.emit_replay_runtime = true;
762        ctx.guest_entry = Some("runGuestProgram".to_string());
763
764        let out = transpile(&ctx);
765        let entry = generated_rust_entry_file(&out);
766        let replay_support = generated_file(&out, "src/replay_support.rs");
767        let cargo_toml = generated_file(&out, "Cargo.toml");
768
769        assert!(entry.contains("aver_replay::with_guest_scope_result(\"runGuestProgram\""));
770        assert!(replay_support.contains("pub mod aver_replay"));
771        assert!(cargo_toml.contains("serde_json = \"1\""));
772    }
773
774    #[test]
775    fn replay_codegen_uses_guest_args_param_override_when_present() {
776        let mut ctx = ctx_from_source(
777            r#"
778module Demo
779
780fn runGuestProgram(path: String, guestArgs: List<String>) -> Result<String, String>
781    ! [Args.get]
782    Result.Ok(String.join(Args.get(), ","))
783"#,
784            "demo",
785        );
786        ctx.emit_replay_runtime = true;
787        ctx.guest_entry = Some("runGuestProgram".to_string());
788
789        let out = transpile(&ctx);
790        let entry = generated_rust_entry_file(&out);
791        let cargo_toml = generated_file(&out, "Cargo.toml");
792
793        assert!(entry.contains("aver_replay::with_guest_scope_args_result(\"runGuestProgram\""));
794        assert!(entry.contains("guestArgs.clone()"));
795        assert!(cargo_toml.contains("edition = \"2024\""));
796    }
797
798    #[test]
799    fn replay_codegen_wraps_root_main_when_no_guest_entry_is_set() {
800        let mut ctx = ctx_from_source(
801            r#"
802module Demo
803
804fn main() -> Result<String, String>
805    ! [Disk.readText]
806    Disk.readText("demo.av")
807"#,
808            "demo",
809        );
810        ctx.emit_replay_runtime = true;
811
812        let out = transpile(&ctx);
813        let root_main = generated_file(&out, "src/main.rs");
814
815        assert!(
816            root_main.contains("aver_replay::with_guest_scope(\"main\", serde_json::Value::Null")
817        );
818    }
819}