Skip to main content

alef_e2e/codegen/rust/
mod.rs

1//! Rust e2e test code generator.
2//!
3//! Generates `e2e/rust/Cargo.toml` and `tests/{category}_test.rs` files from
4//! JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6pub mod assertions;
7pub mod cargo_toml;
8pub mod http;
9pub mod mock_server;
10pub mod test_file;
11
12mod args;
13mod assertion_helpers;
14mod assertion_synthetic;
15
16pub use cargo_toml::render_cargo_toml;
17pub use mock_server::{render_common_module, render_mock_server_binary, render_mock_server_module};
18
19use alef_core::backend::GeneratedFile;
20use alef_core::config::ResolvedCrateConfig;
21use anyhow::Result;
22use std::path::PathBuf;
23
24use crate::config::E2eConfig;
25use crate::escape::sanitize_filename;
26use crate::fixture::{Fixture, FixtureGroup};
27
28use super::E2eCodegen;
29use test_file::{is_skipped, render_test_file};
30
31/// Rust e2e test code generator.
32pub struct RustE2eCodegen;
33
34impl E2eCodegen for RustE2eCodegen {
35    fn generate(
36        &self,
37        groups: &[FixtureGroup],
38        e2e_config: &E2eConfig,
39        config: &ResolvedCrateConfig,
40        _type_defs: &[alef_core::ir::TypeDef],
41    ) -> Result<Vec<GeneratedFile>> {
42        let mut files = Vec::new();
43        let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
44
45        // Resolve crate name and path from config.
46        let crate_name = resolve_crate_name(e2e_config, config);
47        let crate_path = resolve_crate_path(e2e_config, &crate_name);
48        let dep_name = crate_name.replace('-', "_");
49
50        // Cargo.toml
51        // Check if any call config (default or named) uses json_object/handle args (needs serde_json dep).
52        let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
53        let needs_serde_json = all_call_configs
54            .flat_map(|c| c.args.iter())
55            .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
56
57        // Check if any fixture in any group requires a mock HTTP server.
58        // This includes both liter-llm mock_response fixtures and spikard http fixtures.
59        let needs_mock_server = groups
60            .iter()
61            .flat_map(|g| g.fixtures.iter())
62            .any(|f| !is_skipped(f, "rust") && f.needs_mock_server());
63
64        // Check if any fixture uses the http integration test pattern (spikard http fixtures).
65        let needs_http_tests = groups
66            .iter()
67            .flat_map(|g| g.fixtures.iter())
68            .any(|f| !is_skipped(f, "rust") && f.http.is_some());
69
70        // Check if any http fixture uses CORS or static-files middleware (needs tower-http).
71        let needs_tower_http = groups
72            .iter()
73            .flat_map(|g| g.fixtures.iter())
74            .filter(|f| !is_skipped(f, "rust"))
75            .filter_map(|f| f.http.as_ref())
76            .filter_map(|h| h.handler.middleware.as_ref())
77            .any(|m| m.cors.is_some() || m.static_files.is_some());
78
79        // Tokio is needed when any test is async (mock server, http tests, or async call config).
80        let any_async_call = std::iter::once(&e2e_config.call)
81            .chain(e2e_config.calls.values())
82            .any(|c| c.r#async);
83        let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
84
85        let crate_version = resolve_crate_version(e2e_config).or_else(|| config.resolved_version());
86        files.push(GeneratedFile {
87            path: output_base.join("Cargo.toml"),
88            content: render_cargo_toml(
89                &crate_name,
90                &dep_name,
91                &crate_path,
92                needs_serde_json,
93                needs_mock_server,
94                needs_http_tests,
95                needs_tokio,
96                needs_tower_http,
97                e2e_config.dep_mode,
98                crate_version.as_deref(),
99                &config.features,
100            ),
101            generated_header: true,
102        });
103
104        // Generate mock_server.rs when at least one fixture uses mock_response.
105        if needs_mock_server {
106            files.push(GeneratedFile {
107                path: output_base.join("tests").join("mock_server.rs"),
108                content: render_mock_server_module(),
109                generated_header: true,
110            });
111            // Generate common.rs module for spawning the standalone mock-server binary.
112            files.push(GeneratedFile {
113                path: output_base.join("tests").join("common.rs"),
114                content: render_common_module(),
115                generated_header: true,
116            });
117        }
118        // Always generate standalone mock-server binary for cross-language e2e suites
119        // when any fixture has http data (serves fixture responses for non-Rust tests).
120        if needs_mock_server || needs_http_tests {
121            files.push(GeneratedFile {
122                path: output_base.join("src").join("main.rs"),
123                content: render_mock_server_binary(),
124                generated_header: true,
125            });
126        }
127
128        // Per-category test files.
129        for group in groups {
130            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
131
132            if fixtures.is_empty() {
133                continue;
134            }
135
136            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
137            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
138
139            files.push(GeneratedFile {
140                path: output_base.join("tests").join(filename),
141                content,
142                generated_header: true,
143            });
144        }
145
146        Ok(files)
147    }
148
149    fn language_name(&self) -> &'static str {
150        "rust"
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Config resolution helpers
156// ---------------------------------------------------------------------------
157
158fn resolve_crate_name(_e2e_config: &E2eConfig, config: &ResolvedCrateConfig) -> String {
159    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
160    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
161    // import identifier, not the Cargo package name.
162    config.name.clone()
163}
164
165fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
166    e2e_config
167        .resolve_package("rust")
168        .and_then(|p| p.path.clone())
169        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
170}
171
172fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
173    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn resolve_crate_name_uses_config_name() {
182        use alef_core::config::NewAlefConfig;
183        let cfg: NewAlefConfig = toml::from_str(
184            r#"
185[workspace]
186languages = ["rust"]
187
188[[crates]]
189name = "my-lib"
190sources = ["src/lib.rs"]
191
192[crates.e2e]
193fixtures = "fixtures"
194output = "e2e"
195[crates.e2e.call]
196function = "process"
197module = "my_lib"
198result_var = "result"
199"#,
200        )
201        .unwrap();
202        let e2e = cfg.crates[0].e2e.clone().unwrap();
203        let resolved = cfg.resolve().unwrap().remove(0);
204        let name = resolve_crate_name(&e2e, &resolved);
205        assert_eq!(name, "my-lib");
206    }
207}