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
//! Elixir e2e test generator using ExUnit.
use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::sanitize_filename;
use crate::e2e::fixture::{Fixture, FixtureGroup};
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use super::E2eCodegen;
/// Elixir e2e code generator.
pub struct ElixirCodegen;
impl E2eCodegen for ElixirCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
enums: &[crate::core::ir::EnumDef],
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
// Resolve call config with overrides.
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let raw_module = overrides
.and_then(|o| o.module.as_ref())
.cloned()
.unwrap_or_else(|| call.module.clone());
// Convert module path to Elixir PascalCase if it looks like snake_case
// (e.g., "demo_markup" -> "DemoMarkup").
// If the override already contains "." (e.g., "Elixir.DemoMarkup"), use as-is.
let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
raw_module.clone()
} else {
values::elixir_module_name(&raw_module)
};
let base_function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call.function.clone());
// Elixir facade exports async variants with `_async` suffix when the call is async.
// Append the suffix only if not already present and the function isn't a streaming
// entry-point — streaming wrappers (e.g. `defaultclient_chat_stream`) drive the
// FFI iterator handle and aren't async-callable in the OpenAI sense.
let function_name =
if call.r#async && !base_function_name.ends_with("_async") && !base_function_name.ends_with("_stream") {
format!("{base_function_name}_async")
} else {
base_function_name
};
let options_type = overrides.and_then(|o| o.options_type.clone());
let options_default_fn = overrides.and_then(|o| o.options_via.clone());
let empty_enum_fields = HashMap::new();
let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
let empty_atom_fields = std::collections::HashSet::new();
let handle_atom_list_fields = overrides
.map(|o| &o.handle_atom_list_fields)
.unwrap_or(&empty_atom_fields);
let result_var = &call.result_var;
// Check if any fixture in any group is an HTTP test.
let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
// Check if any fixture needs the mock server (either via http or mock_response or client_factory).
let has_mock_server_tests = groups.iter().any(|g| {
g.fixtures.iter().any(|f| {
if f.needs_mock_server() {
return true;
}
let cc = e2e_config.resolve_call_for_fixture(
f.call.as_deref(),
&f.id,
&f.resolved_category(),
&f.tags,
&f.input,
);
let elixir_override = cc
.overrides
.get("elixir")
.or_else(|| e2e_config.call.overrides.get("elixir"));
elixir_override.and_then(|o| o.client_factory.as_deref()).is_some()
})
});
// Resolve package reference (path or version) for the NIF dependency.
let pkg_ref = e2e_config.resolve_package(lang);
let pkg_dep_ref = if has_nif_tests {
match e2e_config.dep_mode {
crate::e2e::config::DependencyMode::Local => pkg_ref
.as_ref()
.and_then(|p| p.path.as_deref())
.unwrap_or("../../packages/elixir")
.to_string(),
crate::e2e::config::DependencyMode::Registry => pkg_ref
.as_ref()
.and_then(|p| p.version.clone())
.or_else(|| config.resolved_version())
.unwrap_or_else(|| "0.1.0".to_string()),
}
} else {
String::new()
};
// Generate mix.exs. The dep atom must match the binding package's
// mix `app:` value, not the crate name. Use the configured
// `[elixir].app_name` (the same source the package's own mix.exs
// uses); fall back to the crate name only when unset. Without this,
// mix's path-dep resolution silently misroutes — the path-dep's
// own deps (notably `:rustler_precompiled`) never load during its
// compilation and the parent build fails with `RustlerPrecompiled
// is not loaded`.
let pkg_atom = config.elixir_app_name();
// Check if there are HTTP fixtures that need server-pattern harness.
let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.http.is_some());
let uses_harness = has_http_fixtures && !e2e_config.harness.imports.is_empty();
files.push(GeneratedFile {
path: output_base.join("mix.exs"),
content: project::render_mix_exs(
&pkg_atom,
&pkg_dep_ref,
e2e_config.dep_mode,
has_http_tests,
has_mock_server_tests,
has_nif_tests,
uses_harness,
),
generated_header: false,
});
// ~keep Without a `.formatter.exs` a bare `mix format` has no `inputs:` and refuses to
// run, so this tree would fall through to poly's own Elixir engine — whose output is not
// mix-canonical (it leaves a call unwrapped where mix breaks it across lines). Emitting
// one is what lets `mix format` own these files, matching `packages/elixir`.
//
// `line_length` matches the binding package's `.formatter.exs` so every generated Elixir
// tree wraps identically. Deliberately no `import_deps: [:rustler]` (which the package
// does use): these projects never invoke rustler's macros, and importing it would make
// formatting fail whenever `deps/` has not been fetched.
files.push(GeneratedFile {
path: output_base.join(".formatter.exs"),
content: "[\n inputs: [\"{mix,.formatter}.exs\", \"{config,lib,test}/**/*.{ex,exs}\"],\n \
line_length: 140\n]\n"
.to_string(),
generated_header: false,
});
// Generate lib/e2e_elixir.ex — required so the mix project compiles.
files.push(GeneratedFile {
path: output_base.join("lib").join("e2e_elixir.ex"),
content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
generated_header: false,
});
// app_harness.exs and the server-pattern test_helper.exs are owned by
// a consumer extension (the e2e extension).
// alef emits test_helper.exs only for the non-harness cases.
if !uses_harness {
files.push(GeneratedFile {
path: output_base.join("test").join("test_helper.exs"),
content: project::render_test_helper(has_http_tests || has_mock_server_tests, e2e_config),
generated_header: false,
});
}
// Generate test files per category.
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.collect();
if active.is_empty() {
continue;
}
let filename = format!("{}_test.exs", sanitize_filename(&group.category));
let content = test_file::render_test_file(
&group.category,
&active,
e2e_config,
&module_path,
&function_name,
result_var,
&e2e_config.call.args,
options_type.as_deref(),
options_default_fn.as_deref(),
enum_fields,
handle_struct_type.as_deref(),
handle_atom_list_fields,
&config.adapters,
enums,
config,
type_defs,
);
files.push(GeneratedFile {
path: output_base.join("test").join(filename),
content,
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"elixir"
}
}
mod args;
mod assertions;
mod http;
mod project;
mod stubs;
mod test_case;
mod test_file;
mod values;
mod visitor;
pub use stubs::emit_test_backend;
#[cfg(test)]
mod tests;