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
//! Ruby e2e argument/rendering helpers.
use crate::core::config::ResolvedCrateConfig;
use crate::e2e::codegen::resolve_field;
use crate::e2e::escape::ruby_string_literal;
use heck::ToSnakeCase;
use std::collections::HashMap;
use super::values::{is_base64, is_file_path, json_to_ruby};
/// Build setup lines (e.g. handle creation) and the argument list for the function call.
///
/// Returns `(setup_lines, args_string)`.
/// Emit Ruby object-array fixture values for a typed `json_object` array.
#[allow(clippy::too_many_arguments)]
pub(super) fn build_args_and_setup(
input: &serde_json::Value,
args: &[crate::e2e::config::ArgMapping],
call_receiver: &str,
module_name: &str,
options_type: Option<&str>,
enum_fields: &HashMap<String, String>,
result_is_simple: bool,
fixture: &crate::e2e::fixture::Fixture,
adapter_request_type: Option<&str>,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
) -> (Vec<String>, String, Vec<String>) {
let fixture_id = &fixture.id;
if args.is_empty() {
// No args config: don't pass the input as a function argument.
// The input data is for setup/mocking purposes only. Functions with no
// parameters must be called with no arguments — not with `{}` or `nil`.
return (Vec::new(), String::new(), Vec::new());
}
let mut setup_lines: Vec<String> = Vec::new();
let mut parts: Vec<String> = Vec::new();
// Teardown lines emitted after the call+assertions. Populated by
// trait-bridge args so RSpec's shared-process registry state is restored
// between tests (e.g. `<Binding>.unregister_<trait>('test-backend')`).
let mut teardown_lines: Vec<String> = Vec::new();
// Track optional args that were skipped; if a later arg is emitted we must back-fill nil
// to preserve positional correctness (e.g. extract_file(path, nil, config)).
let mut skipped_optional_count: usize = 0;
for arg in args {
if arg.arg_type == "mock_url" {
// Flush any pending nil placeholders for skipped optionals before this positional arg.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
if fixture.has_host_root_route() {
let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
setup_lines.push(format!(
"{} = ENV.fetch('{env_key}', nil) || \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
arg.name,
));
} else {
setup_lines.push(format!(
"{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
arg.name,
));
}
if let Some(req_type) = adapter_request_type {
let req_var = format!("{}_req", arg.name);
// Derive the module qualifier from module_name (e.g. "DemoCrawler")
let mod_qualifier = super::values::ruby_module_name(module_name);
setup_lines.push(format!(
"{req_var} = {mod_qualifier}::{req_type}.new(url: {})",
arg.name
));
parts.push(req_var);
} else {
parts.push(arg.name.clone());
}
continue;
}
if arg.arg_type == "mock_url_list" {
// Array of URLs: each element is either a bare path (`/seed1`) — prefixed
// with the per-fixture mock-server URL at runtime — or an absolute URL kept
// as-is. Mirrors `mock_url` resolution: `MOCK_SERVER_<FIXTURE_ID>` first,
// then `MOCK_SERVER_URL/fixtures/<id>`. Without this branch the codegen
// falls back to a JSON-array literal of bare relative paths and the Rust
// HTTP client rejects them.
// Flush any pending nil placeholders before this positional arg.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let val = input.get(field).unwrap_or(&serde_json::Value::Null);
let paths: Vec<String> = if let Some(arr) = val.as_array() {
arr.iter().filter_map(|v| v.as_str().map(ruby_string_literal)).collect()
} else {
Vec::new()
};
let paths_literal = paths.join(", ");
let name = &arg.name;
setup_lines.push(format!(
"{name}_base = ENV.fetch('{env_key}', nil) || \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\""
));
setup_lines.push(format!(
"{name} = [{paths_literal}].map {{ |p| p.start_with?('http') ? p : \"#{{{name}_base}}#{{p}}\" }}"
));
parts.push(name.clone());
continue;
}
// Handle bytes arguments: load from file if needed
if arg.arg_type == "bytes" {
// Flush any pending nil placeholders for skipped optionals before this positional arg.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
let resolved = resolve_field(input, &arg.field);
if let Some(s) = resolved.as_str() {
if is_file_path(s) {
// File path: load with File.read and convert to bytes array
setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
} else if is_base64(s) {
// Base64: decode it
setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
} else {
// Inline text: encode it to binary and convert to bytes array
let escaped = ruby_string_literal(s);
setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
}
parts.push(arg.name.clone());
} else {
parts.push("nil".to_string());
}
continue;
}
// Handle file_path arguments: pass the path string as-is
if arg.arg_type == "file_path" {
// Flush any pending nil placeholders for skipped optionals before this positional arg.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
let resolved = resolve_field(input, &arg.field);
if let Some(s) = resolved.as_str() {
let escaped = ruby_string_literal(s);
parts.push(escaped);
} else if arg.optional {
skipped_optional_count += 1;
continue;
} else {
parts.push("''".to_string());
}
continue;
}
if arg.arg_type == "handle" {
// Flush any pending nil placeholders for skipped optionals before this positional arg.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
// Generate a create_engine (or equivalent) call and pass the variable.
let constructor_name = format!("create_{}", arg.name.to_snake_case());
let config_value = resolve_field(input, &arg.field);
if config_value.is_null()
|| config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
{
setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
} else {
let literal = json_to_ruby(config_value);
let name = &arg.name;
setup_lines.push(format!("{name}_config = {literal}"));
setup_lines.push(format!(
"{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
arg.name,
name = name,
));
}
parts.push(arg.name.clone());
continue;
}
if arg.arg_type == "test_backend" {
// Flush any pending nil placeholders for skipped optionals before this positional arg.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
if let Some(trait_name) = &arg.trait_name {
if let Some(trait_bridge) = config.trait_bridges.iter().find(|tb| tb.trait_name == *trait_name) {
let methods: Vec<&crate::core::ir::MethodDef> = type_defs
.iter()
.find(|t| t.name == *trait_name)
.map(|t| t.methods.iter().collect())
.unwrap_or_default();
let emission = crate::e2e::codegen::emit_test_backend("ruby", trait_bridge, &methods, fixture);
// Split multi-line setup_block into individual lines so the
// Jinja template can indent each line uniformly with ` {{ line }}`.
for line in emission.setup_block.lines() {
setup_lines.push(line.to_string());
}
parts.push(emission.arg_expr);
// For register_fn traits (plugin pattern), Magnus requires a second "name" argument.
// Extract the backend name from fixture input (same logic as emit_test_backend).
if trait_bridge.register_fn.is_some() {
let backend_name = super::stubs::extract_backend_name_from_input(&fixture.input, &fixture.id);
parts.push(ruby_string_literal(&backend_name));
// Emit `<module>.<unregister_fn>('<name>')` after the call so
// RSpec's single-process registry is restored between tests.
// Without this, the next trait-using fixture fails because the test
// registry contains only the test stub and the core's `ensure_*_initialized`
// self-heal only triggers when registry is empty.
if let Some(unregister_fn) = trait_bridge.unregister_fn.as_deref() {
teardown_lines.push(format!(
"{call_receiver}.{unregister_fn}({})",
ruby_string_literal(&backend_name)
));
}
}
continue;
}
}
let emission = crate::e2e::codegen::TestBackendEmission::unimplemented("ruby");
setup_lines.push(format!("# {}", emission.arg_expr));
parts.push("nil".to_string());
continue;
}
let resolved = resolve_field(input, &arg.field);
let val = if resolved.is_null() { None } else { Some(resolved) };
match val {
None | Some(serde_json::Value::Null) if arg.optional => {
// Optional arg with no fixture value: defer; emit nil only if a later arg is present.
skipped_optional_count += 1;
continue;
}
None | Some(serde_json::Value::Null) => {
// Required arg with no fixture value: flush deferred nils, then pass a default.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
let default_val = match arg.arg_type.as_str() {
"string" => "''".to_string(),
"int" | "integer" => "0".to_string(),
"float" | "number" => "0.0".to_string(),
"bool" | "boolean" => "false".to_string(),
_ => "nil".to_string(),
};
parts.push(default_val);
}
Some(v) => {
// Flush deferred nil placeholders for skipped optional args that precede this one.
for _ in 0..skipped_optional_count {
parts.push("nil".to_string());
}
skipped_optional_count = 0;
// For json_object args with options_type, construct a typed options object.
// When result_is_simple, the binding accepts a plain Hash (no wrapper class).
if arg.arg_type == "json_object" && !v.is_null() {
// Check for typed object arrays (element_type set)
if let Some(_elem_type) = &arg.element_type {
if v.is_array() {
if let Some(arr) = v.as_array() {
// Only emit as tagged-enum array if all elements are objects.
// Otherwise fall through to json_to_ruby for primitive arrays (e.g., String, Int).
if !arr.is_empty() && arr.iter().all(|item| item.is_object()) {
parts.push(emit_ruby_object_array(v));
continue;
}
}
// Fall through if array is empty or contains non-objects (primitives)
}
}
// Otherwise handle regular options_type objects
if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
let kwargs: Vec<String> = obj
.iter()
.filter_map(|(k, vv)| {
// Skip empty string values (they cause enum parsing failures)
if let Some(s) = vv.as_str() {
if s.is_empty() {
return None; // Skip all empty strings
}
// For known enum fields, use snake_case enum variant
if enum_fields.contains_key(k) {
let snake_key = k.to_snake_case();
let snake_val = s.to_snake_case();
return Some(format!("{snake_key}: '{snake_val}'"));
}
}
let snake_key = k.to_snake_case();
let rb_val = json_to_ruby(vv);
Some(format!("{snake_key}: {rb_val}"))
})
.collect();
if result_is_simple {
parts.push(format!("{{{}}}", kwargs.join(", ")));
} else {
parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
}
continue;
}
}
parts.push(json_to_ruby(v));
}
}
}
(setup_lines, parts.join(", "), teardown_lines)
}
/// Emit Ruby object-array fixture values for a typed `json_object` array.
pub(super) fn emit_ruby_object_array(arr: &serde_json::Value) -> String {
if let Some(items) = arr.as_array() {
let item_strs: Vec<String> = items
.iter()
.filter_map(|item| {
item.as_object()
.map(|obj| json_to_ruby(&serde_json::Value::Object(obj.clone())))
})
.collect();
format!("[{}]", item_strs.join(", "))
} else {
"[]".to_string()
}
}