Skip to main content

alef_e2e/codegen/rust/
test_file.rs

1//! Per-category test file generation for Rust e2e tests.
2
3use std::fmt::Write as FmtWrite;
4
5use crate::config::E2eConfig;
6use crate::escape::sanitize_filename;
7use crate::field_access::FieldResolver;
8use crate::fixture::{Fixture, FixtureGroup};
9
10use super::args::{emit_rust_visitor_method, render_rust_arg, resolve_visitor_trait};
11use super::assertions::render_assertion;
12use super::http::render_http_test_function;
13use super::mock_server::render_mock_server_setup;
14
15pub(super) fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
16    call_config
17        .overrides
18        .get("rust")
19        .and_then(|o| o.function.clone())
20        .unwrap_or_else(|| call_config.function.clone())
21}
22
23pub(super) fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
24    resolve_module_for_call(&e2e_config.call, dep_name)
25}
26
27pub(super) fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
28    // For Rust, the module name is the crate identifier (underscores).
29    // Priority: override.crate_name > override.module > dep_name
30    let overrides = call_config.overrides.get("rust");
31    overrides
32        .and_then(|o| o.crate_name.clone())
33        .or_else(|| overrides.and_then(|o| o.module.clone()))
34        .unwrap_or_else(|| dep_name.to_string())
35}
36
37pub(super) fn is_skipped(fixture: &Fixture, language: &str) -> bool {
38    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
39}
40
41/// Returns true when the rendered test body contains a word-boundary reference to `symbol`.
42/// Used to decide whether a `use ...::Symbol;` import is needed; emitting it unconditionally
43/// trips `-D unused_imports` for fixtures whose bodies never reference the symbol.
44fn body_references_symbol(body: &str, symbol: &str) -> bool {
45    let bytes = body.as_bytes();
46    let sym = symbol.as_bytes();
47    let n = bytes.len();
48    let m = sym.len();
49    if m == 0 || m > n {
50        return false;
51    }
52    let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
53    let mut i = 0;
54    while i + m <= n {
55        if bytes[i..i + m] == *sym {
56            let before_ok = i == 0 || !is_word(bytes[i - 1]);
57            let after_ok = i + m == n || !is_word(bytes[i + m]);
58            if before_ok && after_ok {
59                return true;
60            }
61        }
62        i += 1;
63    }
64    false
65}
66
67pub fn render_test_file(
68    category: &str,
69    fixtures: &[&Fixture],
70    e2e_config: &E2eConfig,
71    dep_name: &str,
72    needs_mock_server: bool,
73) -> String {
74    let mut out = String::new();
75    out.push_str(&alef_core::hash::header(alef_core::hash::CommentStyle::DoubleSlash));
76    let _ = writeln!(out, "//! E2e tests for category: {category}");
77    let _ = writeln!(out);
78
79    let module = resolve_module(e2e_config, dep_name);
80
81    // Call-based: has mock_response OR is a plain function-call fixture (no http, no mock) with a
82    // configured function name. Pure schema/spec stubs (function name empty) use the stub path.
83    let file_has_call_based = fixtures.iter().any(|f| {
84        if f.mock_response.is_some() {
85            return true;
86        }
87        if f.http.is_none() && f.mock_response.is_none() {
88            let call_config = e2e_config.resolve_call_for_fixture(
89                f.call.as_deref(),
90                &f.id,
91                &f.resolved_category(),
92                &f.tags,
93                &f.input,
94            );
95            let fn_name = resolve_function_name_for_call(call_config);
96            return !fn_name.is_empty();
97        }
98        false
99    });
100
101    // Collect all unique (module, function) pairs needed across call-based fixtures only.
102    // Resolve client_factory from the default call's rust override. When set, the generated tests
103    // create a client via `module::factory(...)` and call methods on it rather than importing and
104    // calling free functions. In that case we skip the function `use` imports entirely.
105    let rust_call_override = e2e_config.call.overrides.get("rust");
106    let client_factory = rust_call_override.and_then(|o| o.client_factory.as_deref());
107
108    // Http fixtures and pure stub fixtures use different code paths and don't import the call function.
109    if file_has_call_based && client_factory.is_none() {
110        let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
111        for fixture in fixtures.iter().filter(|f| {
112            if f.mock_response.is_some() {
113                return true;
114            }
115            if f.http.is_none() && f.mock_response.is_none() {
116                let call_config = e2e_config.resolve_call_for_fixture(
117                    f.call.as_deref(),
118                    &f.id,
119                    &f.resolved_category(),
120                    &f.tags,
121                    &f.input,
122                );
123                let fn_name = resolve_function_name_for_call(call_config);
124                return !fn_name.is_empty();
125            }
126            false
127        }) {
128            let call_config = e2e_config.resolve_call_for_fixture(
129                fixture.call.as_deref(),
130                &fixture.id,
131                &fixture.resolved_category(),
132                &fixture.tags,
133                &fixture.input,
134            );
135            let fn_name = resolve_function_name_for_call(call_config);
136            let mod_name = resolve_module_for_call(call_config, dep_name);
137            imported.insert((mod_name, fn_name));
138        }
139        // Emit use statements, grouping by module when possible.
140        let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
141        for (mod_name, fn_name) in &imported {
142            by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
143        }
144        for (mod_name, fns) in &by_module {
145            if fns.len() == 1 {
146                let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
147            } else {
148                let joined = fns.join(", ");
149                let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
150            }
151        }
152    }
153
154    // Http fixtures reference `App` and `RequestContext` via their fully-qualified
155    // module path (`{dep_name}::App::new()`, `{dep_name}::RequestContext`) — no
156    // top-of-file `use` import is required and emitting one trips `-D unused_imports`.
157
158    // Render test function bodies into a side buffer so we can gate optional imports
159    // on whether the body actually references each imported symbol. Emitting unused
160    // imports trips `-D unused_imports` in the consumer crate.
161    let mut body_buf = String::new();
162    for fixture in fixtures {
163        render_test_function(&mut body_buf, fixture, e2e_config, dep_name, client_factory);
164        let _ = writeln!(body_buf);
165    }
166
167    // Import handle constructor functions and the config type they use.
168    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
169    if has_handle_args && body_references_symbol(&body_buf, "CrawlConfig") {
170        let _ = writeln!(out, "use {module}::CrawlConfig;");
171    }
172    for arg in &e2e_config.call.args {
173        if arg.arg_type == "handle" {
174            use heck::ToSnakeCase;
175            let constructor_name = format!("create_{}", arg.name.to_snake_case());
176            if body_references_symbol(&body_buf, &constructor_name) {
177                let _ = writeln!(out, "use {module}::{constructor_name};");
178            }
179        }
180    }
181
182    // When client_factory is set, emit trait imports required to call methods on the client object.
183    // Traits like LlmClient, FileClient, etc. must be in scope for method dispatch to work.
184    if client_factory.is_some() && file_has_call_based {
185        let trait_imports: Vec<String> = e2e_config
186            .call
187            .overrides
188            .get("rust")
189            .map(|o| o.trait_imports.clone())
190            .unwrap_or_default();
191        for trait_name in &trait_imports {
192            let _ = writeln!(out, "use {module}::{trait_name};");
193        }
194    }
195
196    // Import mock_server and common modules when any fixture in this file uses mock_response.
197    let file_needs_mock = needs_mock_server
198        && fixtures
199            .iter()
200            .any(|f| f.mock_response.is_some() || f.needs_mock_server());
201    if file_needs_mock {
202        let _ = writeln!(out, "mod common;");
203        let _ = writeln!(out, "mod mock_server;");
204        let _ = writeln!(out, "#[allow(unused_imports)]");
205        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
206    }
207
208    // Import the visitor trait, result enum, and node context when any fixture
209    // in this file declares a `visitor` block. Without these, the inline
210    // `impl <visitor_trait> for _TestVisitor` block fails to resolve.
211    // Visitor types live in the `visitor` sub-module of the crate, not the crate root.
212    // The trait name is read from `[e2e.call.overrides.rust] visitor_trait`; omitting it
213    // while a fixture declares a visitor is a configuration error.
214    let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
215    if file_needs_visitor {
216        let visitor_trait = resolve_visitor_trait(rust_call_override).unwrap_or_else(|| {
217            panic!(
218                "category '{}': fixture declares a visitor block but \
219                 `[e2e.call.overrides.rust] visitor_trait` is not configured",
220                category
221            )
222        });
223        let _ = writeln!(
224            out,
225            "use {module}::visitor::{{{visitor_trait}, NodeContext, VisitResult}};"
226        );
227    }
228
229    // When the rust override specifies an `options_type` (e.g. `ConversionOptions`),
230    // type annotations are emitted on json_object bindings so that `Default::default()`
231    // and `serde_json::from_value(…)` can be resolved without a trailing positional arg.
232    // Import the named type so it is in scope in every test function in this file.
233    if file_has_call_based {
234        let rust_options_type = e2e_config
235            .call
236            .overrides
237            .get("rust")
238            .and_then(|o| o.options_type.as_deref());
239        if let Some(opts_type) = rust_options_type {
240            // Only emit if the call has a json_object arg (the type annotation is only
241            // added to json_object bindings).
242            let has_json_object_arg = e2e_config.call.args.iter().any(|a| a.arg_type == "json_object");
243            if has_json_object_arg && body_references_symbol(&body_buf, opts_type) {
244                let _ = writeln!(out, "use {module}::{opts_type};");
245            }
246        }
247    }
248
249    // Collect and import element types from json_object args that have an element_type specified.
250    // These types are used in serde_json::from_value::<Vec<{elem}>>() for batch operations.
251    // Collect from all calls used in call-based fixtures (not just the default call).
252    if file_has_call_based {
253        let mut element_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
254        for fixture in fixtures.iter().filter(|f| {
255            if f.mock_response.is_some() {
256                return true;
257            }
258            if f.http.is_none() && f.mock_response.is_none() {
259                let call_config = e2e_config.resolve_call_for_fixture(
260                    f.call.as_deref(),
261                    &f.id,
262                    &f.resolved_category(),
263                    &f.tags,
264                    &f.input,
265                );
266                let fn_name = resolve_function_name_for_call(call_config);
267                return !fn_name.is_empty();
268            }
269            false
270        }) {
271            let call_config = e2e_config.resolve_call_for_fixture(
272                fixture.call.as_deref(),
273                &fixture.id,
274                &fixture.resolved_category(),
275                &fixture.tags,
276                &fixture.input,
277            );
278            for arg in &call_config.args {
279                if arg.arg_type == "json_object" {
280                    if let Some(ref elem_type) = arg.element_type {
281                        element_types.insert(elem_type.clone());
282                    }
283                }
284            }
285        }
286        for elem_type in &element_types {
287            // Skip primitive / std types — they're already in scope via the Rust prelude
288            // and emitting `use kreuzberg::String;` (etc.) would fail with E0432.
289            if matches!(
290                elem_type.as_str(),
291                "String"
292                    | "str"
293                    | "bool"
294                    | "i8"
295                    | "i16"
296                    | "i32"
297                    | "i64"
298                    | "i128"
299                    | "isize"
300                    | "u8"
301                    | "u16"
302                    | "u32"
303                    | "u64"
304                    | "u128"
305                    | "usize"
306                    | "f32"
307                    | "f64"
308                    | "char"
309            ) {
310                continue;
311            }
312            if body_references_symbol(&body_buf, elem_type) {
313                let _ = writeln!(out, "use {module}::{elem_type};");
314            }
315        }
316    }
317
318    let _ = writeln!(out);
319    out.push_str(&body_buf);
320
321    if !out.ends_with('\n') {
322        out.push('\n');
323    }
324    out
325}
326
327pub fn render_test_function(
328    out: &mut String,
329    fixture: &Fixture,
330    e2e_config: &E2eConfig,
331    dep_name: &str,
332    client_factory: Option<&str>,
333) {
334    // Http fixtures get their own integration test code path.
335    if fixture.http.is_some() {
336        render_http_test_function(out, fixture, dep_name);
337        return;
338    }
339
340    // Fixtures that have neither `http` nor `mock_response` may be either:
341    //  - schema/spec validation fixtures (asyncapi, grpc, graphql_schema, …) with no callable
342    //    function → emit a TODO stub so the suite compiles and preserves test count.
343    //  - plain function-call fixtures (e.g. kreuzberg::extract_file) with a configured
344    //    `[e2e.call]` → fall through to the real function-call code path below.
345    if fixture.http.is_none() && fixture.mock_response.is_none() {
346        let call_config = e2e_config.resolve_call_for_fixture(
347            fixture.call.as_deref(),
348            &fixture.id,
349            &fixture.resolved_category(),
350            &fixture.tags,
351            &fixture.input,
352        );
353        let resolved_fn_name = resolve_function_name_for_call(call_config);
354        if resolved_fn_name.is_empty() {
355            let fn_name = crate::escape::sanitize_ident(&fixture.id);
356            let description = &fixture.description;
357            let _ = writeln!(out, "#[tokio::test]");
358            let _ = writeln!(out, "async fn test_{fn_name}() {{");
359            let _ = writeln!(out, "    // {description}");
360            let _ = writeln!(
361                out,
362                "    // TODO: implement when a callable API is available for this fixture type."
363            );
364            let _ = writeln!(out, "}}");
365            return;
366        }
367        // Non-empty function name: fall through to emit a real function call below.
368    }
369
370    let fn_name = crate::escape::sanitize_ident(&fixture.id);
371    let description = &fixture.description;
372    let call_config = e2e_config.resolve_call_for_fixture(
373        fixture.call.as_deref(),
374        &fixture.id,
375        &fixture.resolved_category(),
376        &fixture.tags,
377        &fixture.input,
378    );
379    // Per-call field resolver: overrides the file-level resolver when this call
380    // declares its own result_fields / fields / fields_optional / fields_array /
381    // fields_method_calls.
382    let call_field_resolver = FieldResolver::new(
383        e2e_config.effective_fields(call_config),
384        e2e_config.effective_fields_optional(call_config),
385        e2e_config.effective_result_fields(call_config),
386        e2e_config.effective_fields_array(call_config),
387        e2e_config.effective_fields_method_calls(call_config),
388    );
389    let field_resolver = &call_field_resolver;
390    let function_name = resolve_function_name_for_call(call_config);
391    let module = resolve_module_for_call(call_config, dep_name);
392    let result_var = &call_config.result_var;
393    let has_mock = fixture.mock_response.is_some();
394
395    // Resolve Rust-specific overrides early since we need them for returns_result.
396    let rust_overrides = call_config.overrides.get("rust");
397
398    // Determine if this call returns Result<T, E>. Per-rust override takes precedence.
399    // When client_factory is set, methods always return Result<T>.
400    let returns_result = rust_overrides
401        .and_then(|o| o.returns_result)
402        .unwrap_or(if client_factory.is_some() {
403            true
404        } else {
405            call_config.returns_result
406        });
407
408    // Tests with a mock server are always async (Axum requires a Tokio runtime).
409    let is_async = call_config.r#async || has_mock;
410    if is_async {
411        let _ = writeln!(out, "#[tokio::test]");
412        let _ = writeln!(out, "async fn test_{fn_name}() {{");
413    } else {
414        let _ = writeln!(out, "#[test]");
415        let _ = writeln!(out, "fn test_{fn_name}() {{");
416    }
417    let _ = writeln!(out, "    // {description}");
418
419    // Render the rest of the function body into a side buffer so we can post-process
420    // the `mock_server` binding name: if the body never reads `mock_server.url` (typical
421    // for error-path fixtures that only need the server held alive via Drop), we rename
422    // the binding to `_mock_server` to silence `-D unused_variables`. The original `out`
423    // is renamed to `final_out`; the inner code below writes to `out` (the body buffer).
424    let final_out = out;
425    let mut body_buf = String::new();
426    let out = &mut body_buf;
427
428    // Check if any assertion is an error assertion.
429    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
430
431    // Extract additional overrides for argument shaping.
432    let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
433    let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
434    // options_type from the rust override (e.g. "ConversionOptions") — used to annotate
435    // `Default::default()` and `serde_json::from_value(…)` bindings so Rust can infer
436    // the concrete type without a trailing positional argument to guide inference.
437    let options_type: Option<String> = rust_overrides.and_then(|o| o.options_type.clone());
438
439    // When the fixture declares a visitor that is passed via an options-field (the
440    // html-to-markdown core `convert` API accepts visitor only through
441    // `ConversionOptions.visitor`), the options binding must be `mut` so we can
442    // assign the visitor field before the call.
443    let visitor_via_options = fixture.visitor.is_some() && rust_overrides.is_none_or(|o| o.visitor_function.is_none());
444
445    // Emit input variable bindings from args config.
446    let mut arg_exprs: Vec<String> = Vec::new();
447    // Track the name of the json_object options arg so we can inject the visitor later.
448    let mut options_arg_name: Option<String> = None;
449    // When has_error_assertion is true and a handle arg is present, track its name so we
450    // can wrap the main call in a match that propagates engine-creation failures as Err.
451    let mut error_context_handle_name: Option<String> = None;
452    for arg in &call_config.args {
453        let value = crate::codegen::resolve_field(&fixture.input, &arg.field);
454        let var_name = &arg.name;
455        let (mut bindings, expr) = render_rust_arg(
456            var_name,
457            value,
458            &arg.arg_type,
459            arg.optional,
460            &module,
461            &fixture.id,
462            if has_mock {
463                Some("mock_server.url.as_str()")
464            } else {
465                None
466            },
467            arg.owned,
468            arg.element_type.as_deref(),
469            &e2e_config.test_documents_dir,
470            has_error_assertion,
471        );
472        // Add explicit type annotation to json_object bindings so Rust can resolve
473        // `Default::default()` and `serde_json::from_value(…)` without a trailing
474        // positional argument to guide inference.
475        if arg.arg_type == "json_object" {
476            if let Some(ref opts_type) = options_type {
477                bindings = bindings
478                    .into_iter()
479                    .map(|b| {
480                        // `let {name} = …` → `let {name}: {opts_type} = …`
481                        let prefix = format!("let {var_name} = ");
482                        if b.starts_with(&prefix) {
483                            format!("let {var_name}: {opts_type} = {}", &b[prefix.len()..])
484                        } else {
485                            b
486                        }
487                    })
488                    .collect();
489            }
490        }
491        // When the visitor will be injected via the options field, the options binding
492        // must be declared `mut` so we can assign `options.visitor = Some(visitor)`.
493        if visitor_via_options && arg.arg_type == "json_object" {
494            options_arg_name = Some(var_name.clone());
495            bindings = bindings
496                .into_iter()
497                .map(|b| {
498                    // `let {name}` → `let mut {name}`
499                    let prefix = format!("let {var_name}");
500                    if b.starts_with(&prefix) {
501                        format!("let mut {}", &b[4..])
502                    } else {
503                        b
504                    }
505                })
506                .collect();
507        }
508        // When in error context and the arg is a handle, the binding emitted by
509        // render_rust_arg is `{name}_result` (a Result). Track the handle name so
510        // the error-context call site can emit a match wrapper.
511        if has_error_assertion && arg.arg_type == "handle" {
512            error_context_handle_name = Some(var_name.clone());
513        }
514        for binding in &bindings {
515            let _ = writeln!(out, "    {binding}");
516        }
517        // For functions whose options slot is owned `Option<T>` rather than `&T`,
518        // wrap the json_object expression in `Some(...).clone()` so it matches
519        // the parameter shape. Other arg types pass through unchanged.
520        let final_expr = if has_error_assertion && arg.arg_type == "handle" {
521            // In error context, the handle binding is `{name}_result` (a Result).
522            // The actual call will be emitted inside a `match {name}_result { Ok({name}) => ...}`
523            // wrapper, so the call expression still uses `&{name}` (the unwrapped handle).
524            format!("&{var_name}")
525        } else if wrap_options_in_some && arg.arg_type == "json_object" {
526            if visitor_via_options {
527                // Visitor will be injected into options before the call; pass by move
528                // (no .clone() needed).
529                let name = if let Some(rest) = expr.strip_prefix('&') {
530                    rest.to_string()
531                } else {
532                    expr.clone()
533                };
534                format!("Some({name})")
535            } else if let Some(rest) = expr.strip_prefix('&') {
536                format!("Some({rest}.clone())")
537            } else {
538                format!("Some({expr})")
539            }
540        } else {
541            expr
542        };
543        arg_exprs.push(final_expr);
544    }
545
546    // Emit visitor if present in fixture.
547    if let Some(visitor_spec) = &fixture.visitor {
548        // The visitor trait name must be configured via
549        // `[e2e.call.overrides.rust] visitor_trait`; we propagate the error to the caller.
550        let visitor_trait = resolve_visitor_trait(rust_overrides)
551            .expect("visitor_trait must be set in [e2e.call.overrides.rust] when a fixture declares a visitor block");
552        // The visitor trait requires `std::fmt::Debug`; derive it on the inline struct.
553        let _ = writeln!(out, "    #[derive(Debug)]");
554        let _ = writeln!(out, "    struct _TestVisitor;");
555        let _ = writeln!(out, "    impl {visitor_trait} for _TestVisitor {{");
556        for (method_name, action) in &visitor_spec.callbacks {
557            emit_rust_visitor_method(out, method_name, action);
558        }
559        let _ = writeln!(out, "    }}");
560        let _ = writeln!(
561            out,
562            "    let visitor = std::sync::Arc::new(std::sync::Mutex::new(_TestVisitor));"
563        );
564        if visitor_via_options {
565            // Inject the visitor via the options field rather than as a positional arg.
566            let opts_name = options_arg_name.as_deref().unwrap_or("options");
567            let _ = writeln!(out, "    {opts_name}.visitor = Some(visitor);");
568        } else {
569            // Binding uses a visitor_function override that takes visitor as positional arg.
570            arg_exprs.push("Some(visitor)".to_string());
571        }
572    } else {
573        // No fixture-supplied visitor: append any extra positional args declared in
574        // the rust override (e.g. trailing `None` for an Option<VisitorParam> slot).
575        arg_exprs.extend(extra_args);
576    }
577
578    let args_str = arg_exprs.join(", ");
579
580    let await_suffix = if is_async { ".await" } else { "" };
581
582    // When client_factory is configured, emit a `create_client` call and dispatch
583    // methods on the returned client object instead of calling free functions.
584    // The mock server URL (when present) is passed as `base_url`; otherwise `None`.
585    let call_expr = if let Some(factory) = client_factory {
586        let base_url_arg = if has_mock {
587            "Some(mock_server.url.clone())"
588        } else {
589            "None"
590        };
591        let _ = writeln!(
592            out,
593            "    let client = {module}::{factory}(\"test-key\".to_string(), {base_url_arg}, None, None, None).unwrap();"
594        );
595        format!("client.{function_name}({args_str})")
596    } else {
597        format!("{function_name}({args_str})")
598    };
599
600    let result_is_tree = call_config.result_var == "tree";
601    // When the call config or rust override sets result_is_simple, the function
602    // returns a plain type (String, Vec<T>, etc.) — field-access assertions use
603    // the result var directly.
604    let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
605    // When result_is_vec is set, the function returns Vec<T>. Field-path assertions
606    // are wrapped in `.iter().all(|r| ...)` so every element is checked.
607    let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
608    // When result_is_option is set, the function returns Option<T>. Field-path
609    // assertions unwrap first via `.as_ref().expect("Option should be Some")`.
610    let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
611
612    if has_error_assertion {
613        // When a handle (engine) arg is present, the engine creation itself may fail.
614        // Wrap the primary call in a match so engine-creation errors propagate as Err
615        // instead of panicking via .expect().
616        if let Some(ref handle_name) = error_context_handle_name {
617            let _ = writeln!(out, "    let {result_var} = match {handle_name}_result {{");
618            let _ = writeln!(out, "        Err(e) => Err(e),");
619            let _ = writeln!(out, "        Ok({handle_name}) => {{");
620            let _ = writeln!(out, "            {call_expr}{await_suffix}");
621            let _ = writeln!(out, "        }}");
622            let _ = writeln!(out, "    }};");
623        } else {
624            let _ = writeln!(out, "    let {result_var} = {call_expr}{await_suffix};");
625        }
626        // Check if any assertion accesses fields on the Ok value (not error-path fields).
627        // Assertions on `error.*` fields access the Err value and do not need `result_ok`.
628        let has_non_error_assertions = fixture.assertions.iter().any(|a| {
629            !matches!(a.assertion_type.as_str(), "error" | "not_error")
630                && !a.field.as_ref().is_some_and(|f| f.starts_with("error."))
631        });
632        // When returns_result=true and there are field assertions (non-error), we need to
633        // handle the Result wrapper: unwrap Ok for field assertions, extract Err for error assertions.
634        if returns_result && has_non_error_assertions {
635            // Emit a temporary binding for the unwrapped Ok value.
636            let _ = writeln!(out, "    let {result_var}_ok = {result_var}.as_ref().ok();");
637        }
638        // Render error assertions.
639        for assertion in &fixture.assertions {
640            render_assertion(
641                out,
642                assertion,
643                result_var,
644                &module,
645                dep_name,
646                true,
647                &[],
648                field_resolver,
649                result_is_tree,
650                result_is_simple,
651                false,
652                false,
653                returns_result,
654            );
655        }
656        let _ = writeln!(out, "}}");
657        finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
658        return;
659    }
660
661    // Non-error path: unwrap the result.
662    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
663
664    // Streaming detection (call-level `streaming` opt-out is honored).
665    // Shared helper auto-detects from unambiguous streaming-only field names
666    // (chunks, stream_content, …) — but not from ambiguous fields like `usage`
667    // or `finish_reason` that also exist on non-streaming response shapes.
668    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
669    // Name of the stream-level variable (the raw stream returned by the call).
670    let stream_var = "stream";
671    // Name of the collected-list variable produced by draining the stream.
672    let chunks_var = "chunks";
673
674    // Check if any assertion actually uses the result variable.
675    // If all assertions are skipped (field not on result type), use `_` to avoid
676    // Rust's "variable never used" warning.
677    // For streaming fixtures: streaming virtual fields count as usable — they resolve
678    // against the `chunks` collected variable rather than the result type.
679    let has_usable_assertion = fixture.assertions.iter().any(|a| {
680        if a.assertion_type == "not_error" || a.assertion_type == "error" {
681            return false;
682        }
683        if a.assertion_type == "method_result" {
684            // method_result assertions that would generate only a TODO comment don't use the
685            // result variable. These are: missing `method` field, or unsupported `check` type.
686            let supported_checks = [
687                "equals",
688                "is_true",
689                "is_false",
690                "greater_than_or_equal",
691                "count_min",
692                "is_error",
693                "contains",
694                "not_empty",
695                "is_empty",
696            ];
697            let check = a.check.as_deref().unwrap_or("is_true");
698            if a.method.is_none() || !supported_checks.contains(&check) {
699                return false;
700            }
701        }
702        match &a.field {
703            Some(f) if !f.is_empty() => {
704                if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
705                    return true;
706                }
707                field_resolver.is_valid_for_result(f)
708            }
709            _ => true,
710        }
711    });
712
713    // For streaming fixtures the stream itself is stored in `stream` and the
714    // collected list in `chunks`.  Non-streaming fixtures use result_var / `_`.
715    let result_binding = if is_streaming {
716        stream_var.to_string()
717    } else if has_usable_assertion {
718        result_var.to_string()
719    } else {
720        "_".to_string()
721    };
722
723    // Detect Option-returning functions: only skip unwrap when ALL assertions are
724    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
725    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
726    let has_field_access = fixture
727        .assertions
728        .iter()
729        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
730    let only_emptiness_checks = !has_field_access
731        && fixture.assertions.iter().all(|a| {
732            matches!(
733                a.assertion_type.as_str(),
734                "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
735            )
736        });
737
738    let unwrap_suffix = if returns_result {
739        ".expect(\"should succeed\")"
740    } else {
741        ""
742    };
743    if is_streaming {
744        // Streaming: bind the raw stream, then drain it into a Vec.
745        let _ = writeln!(out, "    let {stream_var} = {call_expr}{await_suffix}{unwrap_suffix};");
746        if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
747            "rust", stream_var, chunks_var,
748        ) {
749            let _ = writeln!(out, "    {collect}");
750        }
751    } else if !returns_result || (only_emptiness_checks && !has_not_error) {
752        // Option-returning or non-Result-returning (and not a not_error check): bind raw value, no unwrap.
753        // When returns_result=true and has_not_error, fall through to emit .expect() so errors panic.
754        let _ = writeln!(out, "    let {result_binding} = {call_expr}{await_suffix};");
755    } else if has_not_error || !fixture.assertions.is_empty() {
756        let _ = writeln!(
757            out,
758            "    let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
759        );
760    } else {
761        let _ = writeln!(out, "    let {result_binding} = {call_expr}{await_suffix};");
762    }
763
764    // Emit local bindings for fields_array fields that are referenced in assertions
765    // AND whose names collide with streaming-virtual field names (e.g. `chunks`,
766    // `imports`, `structure`).  Without this, the streaming-virtual-field arm in
767    // `render_assertion` fires unconditionally on those field names — even for
768    // non-streaming fixtures — and emits `assert!(chunks.len() >= 2 as usize, ...)`
769    // referencing an undeclared variable.  Binding `let chunks = &result.chunks;`
770    // here makes the hardcoded `chunks` identifier in that arm resolve correctly.
771    //
772    // Only emit for non-streaming fixtures: streaming fixtures already drain the
773    // stream into a `chunks: Vec<_>` local via the collect snippet.
774    if !is_streaming {
775        let mut emitted_array_bindings: std::collections::HashSet<String> = std::collections::HashSet::new();
776        for assertion in &fixture.assertions {
777            if let Some(f) = &assertion.field {
778                if !f.is_empty()
779                    && field_resolver.is_array(f)
780                    && crate::codegen::streaming_assertions::is_streaming_virtual_field(f)
781                    && !emitted_array_bindings.contains(f.as_str())
782                {
783                    let accessor = field_resolver.accessor(f, "rust", result_var);
784                    let _ = writeln!(out, "    let {f} = &{accessor};");
785                    emitted_array_bindings.insert(f.clone());
786                }
787            }
788        }
789    }
790
791    // Emit Option field unwrap bindings for any fields accessed in assertions.
792    // Use FieldResolver to handle optional fields, including nested/aliased paths.
793    // Skipped when the call returns Vec<T>: per-element iteration is emitted by
794    // `render_assertion` itself, so the call-site has no single result struct
795    // to unwrap fields off of.
796    let string_assertion_types = [
797        "equals",
798        "contains",
799        "contains_all",
800        "contains_any",
801        "not_contains",
802        "starts_with",
803        "ends_with",
804        "min_length",
805        "max_length",
806        "matches_regex",
807    ];
808    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
809    if !result_is_vec {
810        for assertion in &fixture.assertions {
811            if let Some(f) = &assertion.field {
812                if !f.is_empty()
813                    && string_assertion_types.contains(&assertion.assertion_type.as_str())
814                    && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
815                {
816                    // Only unwrap optional string fields — numeric optionals (u64, usize)
817                    // don't support .as_deref() and should be compared directly.
818                    let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
819                    if !is_string_assertion {
820                        continue;
821                    }
822                    if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
823                        let _ = writeln!(out, "    {binding}");
824                        unwrapped_fields.push((f.clone(), local_var));
825                    }
826                }
827            }
828        }
829    }
830
831    // Render assertions.
832    for assertion in &fixture.assertions {
833        if assertion.assertion_type == "not_error" {
834            // Already handled by .expect() above.
835            continue;
836        }
837        render_assertion(
838            out,
839            assertion,
840            result_var,
841            &module,
842            dep_name,
843            false,
844            &unwrapped_fields,
845            field_resolver,
846            result_is_tree,
847            result_is_simple,
848            result_is_vec,
849            result_is_option,
850            returns_result,
851        );
852    }
853
854    let _ = writeln!(out, "}}");
855    finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
856}
857
858/// Emit mock-server setup (if needed) and the rendered body to the test file output.
859///
860/// The body buffer is scanned for references to `mock_server.` to decide the binding name:
861/// if any non-setup reference exists, the binding is `mock_server`; otherwise it is
862/// `_mock_server` to silence `-D unused_variables` while still keeping the server alive
863/// via Drop. Error-path fixtures typically fall into the latter case — they need the
864/// server bound to a name but never read `mock_server.url`.
865fn finalize_test_body(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, has_mock: bool, body: &str) {
866    if has_mock {
867        let var_name = if body.contains("mock_server.") {
868            "mock_server"
869        } else {
870            "_mock_server"
871        };
872        render_mock_server_setup(out, fixture, e2e_config, var_name);
873    }
874    out.push_str(body);
875}
876
877/// Collect test file names for use in build.zig and similar build scripts.
878pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
879    groups
880        .iter()
881        .filter(|g| !g.fixtures.is_empty())
882        .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
883        .collect()
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    #[test]
891    fn resolve_module_for_call_prefers_crate_name_override() {
892        use crate::config::CallConfig;
893        use std::collections::HashMap;
894        let mut overrides = HashMap::new();
895        overrides.insert(
896            "rust".to_string(),
897            crate::config::CallOverride {
898                crate_name: Some("custom_crate".to_string()),
899                module: Some("ignored_module".to_string()),
900                ..Default::default()
901            },
902        );
903        let call = CallConfig {
904            overrides,
905            ..Default::default()
906        };
907        let result = resolve_module_for_call(&call, "dep_name");
908        assert_eq!(result, "custom_crate");
909    }
910
911    /// Regression test: a non-streaming fixture whose result struct has a `chunks`
912    /// field (registered in `fields_array`) must emit `let chunks = &result.chunks;`
913    /// before any assertion so the streaming-virtual-field arm's hardcoded `chunks`
914    /// identifier resolves.  Without the fix this generated
915    /// `assert!(chunks.len() >= 2 as usize, ...)` with `chunks` undeclared.
916    #[test]
917    fn fields_array_binding_emitted_before_count_min_assertion_for_non_streaming_fixture() {
918        use crate::config::CallConfig;
919        use crate::fixture::{Assertion, Fixture};
920        use std::collections::HashSet;
921
922        let mut fields_array = HashSet::new();
923        fields_array.insert("chunks".to_string());
924
925        let call = CallConfig {
926            function: "process".to_string(),
927            module: "my_crate".to_string(),
928            result_var: "result".to_string(),
929            fields_array,
930            returns_result: true,
931            streaming: Some(false),
932            ..Default::default()
933        };
934
935        let e2e_config = crate::config::E2eConfig {
936            call,
937            ..Default::default()
938        };
939
940        let fixture = Fixture {
941            id: "chunking_test".to_string(),
942            description: "Chunking produces multiple pieces".to_string(),
943            tags: Vec::new(),
944            skip: None,
945            env: None,
946            call: None,
947            input: serde_json::Value::Null,
948            mock_response: None,
949            visitor: None,
950            assertions: vec![Assertion {
951                assertion_type: "count_min".to_string(),
952                field: Some("chunks".to_string()),
953                value: Some(serde_json::Value::Number(serde_json::Number::from(2u64))),
954                values: None,
955                method: None,
956                check: None,
957                args: None,
958                return_type: None,
959            }],
960            source: String::new(),
961            http: None,
962            category: None,
963        };
964
965        let mut out = String::new();
966        render_test_function(&mut out, &fixture, &e2e_config, "my_crate", None);
967
968        assert!(
969            out.contains("let chunks = &result.chunks"),
970            "expected `let chunks = &result.chunks` binding before assertion; got:\n{out}"
971        );
972        assert!(
973            out.contains("chunks.len() >= 2"),
974            "expected count_min assertion referencing `chunks`; got:\n{out}"
975        );
976        // The binding must appear before the assertion in the output.
977        let binding_pos = out.find("let chunks = &result.chunks").unwrap();
978        let assert_pos = out.find("chunks.len() >= 2").unwrap();
979        assert!(
980            binding_pos < assert_pos,
981            "binding must appear before assertion; got:\n{out}"
982        );
983    }
984}