Skip to main content

alef_e2e/codegen/
rust.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
6use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, CorsConfig, Fixture, FixtureGroup, StaticFilesConfig};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions as tv;
15use anyhow::Result;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19/// Rust e2e test code generator.
20pub struct RustE2eCodegen;
21
22impl super::E2eCodegen for RustE2eCodegen {
23    fn generate(
24        &self,
25        groups: &[FixtureGroup],
26        e2e_config: &E2eConfig,
27        alef_config: &AlefConfig,
28    ) -> Result<Vec<GeneratedFile>> {
29        let mut files = Vec::new();
30        let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
31
32        // Resolve crate name and path from config.
33        let crate_name = resolve_crate_name(e2e_config, alef_config);
34        let crate_path = resolve_crate_path(e2e_config, &crate_name);
35        let dep_name = crate_name.replace('-', "_");
36
37        // Cargo.toml
38        // Check if any call config (default or named) uses json_object/handle args (needs serde_json dep).
39        let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
40        let needs_serde_json = all_call_configs
41            .flat_map(|c| c.args.iter())
42            .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
43
44        // Check if any fixture in any group requires a mock HTTP server.
45        // This includes both liter-llm mock_response fixtures and spikard http fixtures.
46        let needs_mock_server = groups
47            .iter()
48            .flat_map(|g| g.fixtures.iter())
49            .any(|f| !is_skipped(f, "rust") && f.mock_response.is_some());
50
51        // Check if any fixture uses the http integration test pattern (spikard http fixtures).
52        let needs_http_tests = groups
53            .iter()
54            .flat_map(|g| g.fixtures.iter())
55            .any(|f| !is_skipped(f, "rust") && f.http.is_some());
56
57        // Check if any http fixture uses CORS or static-files middleware (needs tower-http).
58        let needs_tower_http = groups
59            .iter()
60            .flat_map(|g| g.fixtures.iter())
61            .filter(|f| !is_skipped(f, "rust"))
62            .filter_map(|f| f.http.as_ref())
63            .filter_map(|h| h.handler.middleware.as_ref())
64            .any(|m| m.cors.is_some() || m.static_files.is_some());
65
66        // Tokio is needed when any test is async (mock server, http tests, or async call config).
67        let any_async_call = std::iter::once(&e2e_config.call)
68            .chain(e2e_config.calls.values())
69            .any(|c| c.r#async);
70        let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
71
72        let crate_version = resolve_crate_version(e2e_config);
73        files.push(GeneratedFile {
74            path: output_base.join("Cargo.toml"),
75            content: render_cargo_toml(
76                &crate_name,
77                &dep_name,
78                &crate_path,
79                needs_serde_json,
80                needs_mock_server,
81                needs_http_tests,
82                needs_tokio,
83                needs_tower_http,
84                e2e_config.dep_mode,
85                crate_version.as_deref(),
86                &alef_config.crate_config.features,
87            ),
88            generated_header: true,
89        });
90
91        // Generate mock_server.rs when at least one fixture uses mock_response.
92        if needs_mock_server {
93            files.push(GeneratedFile {
94                path: output_base.join("tests").join("mock_server.rs"),
95                content: render_mock_server_module(),
96                generated_header: true,
97            });
98        }
99        // Always generate standalone mock-server binary for cross-language e2e suites
100        // when any fixture has http data (serves fixture responses for non-Rust tests).
101        if needs_mock_server || needs_http_tests {
102            files.push(GeneratedFile {
103                path: output_base.join("src").join("main.rs"),
104                content: render_mock_server_binary(),
105                generated_header: true,
106            });
107        }
108
109        // Per-category test files.
110        for group in groups {
111            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
112
113            if fixtures.is_empty() {
114                continue;
115            }
116
117            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
118            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
119
120            files.push(GeneratedFile {
121                path: output_base.join("tests").join(filename),
122                content,
123                generated_header: true,
124            });
125        }
126
127        Ok(files)
128    }
129
130    fn language_name(&self) -> &'static str {
131        "rust"
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Config resolution helpers
137// ---------------------------------------------------------------------------
138
139fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
140    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
141    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
142    // import identifier, not the Cargo package name.
143    alef_config.crate_config.name.clone()
144}
145
146fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
147    e2e_config
148        .resolve_package("rust")
149        .and_then(|p| p.path.clone())
150        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
151}
152
153fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
154    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
155}
156
157fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
158    call_config
159        .overrides
160        .get("rust")
161        .and_then(|o| o.function.clone())
162        .unwrap_or_else(|| call_config.function.clone())
163}
164
165fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
166    resolve_module_for_call(&e2e_config.call, dep_name)
167}
168
169fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
170    // For Rust, the module name is the crate identifier (underscores).
171    // Priority: override.crate_name > override.module > dep_name
172    let overrides = call_config.overrides.get("rust");
173    overrides
174        .and_then(|o| o.crate_name.clone())
175        .or_else(|| overrides.and_then(|o| o.module.clone()))
176        .unwrap_or_else(|| dep_name.to_string())
177}
178
179fn is_skipped(fixture: &Fixture, language: &str) -> bool {
180    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
181}
182
183// ---------------------------------------------------------------------------
184// Rendering
185// ---------------------------------------------------------------------------
186
187#[allow(clippy::too_many_arguments)]
188pub fn render_cargo_toml(
189    crate_name: &str,
190    dep_name: &str,
191    crate_path: &str,
192    needs_serde_json: bool,
193    needs_mock_server: bool,
194    needs_http_tests: bool,
195    needs_tokio: bool,
196    needs_tower_http: bool,
197    dep_mode: crate::config::DependencyMode,
198    version: Option<&str>,
199    features: &[String],
200) -> String {
201    let e2e_name = format!("{dep_name}-e2e-rust");
202    // Use only the features explicitly configured in alef.toml.
203    // Do NOT auto-add "serde" — the target crate may not have that feature.
204    // serde_json is added as a separate dependency when needed.
205    let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
206    let features_str = if effective_features.is_empty() {
207        String::new()
208    } else {
209        format!(", default-features = false, features = {:?}", effective_features)
210    };
211    let dep_spec = match dep_mode {
212        crate::config::DependencyMode::Registry => {
213            let ver = version.unwrap_or("0.1.0");
214            if crate_name != dep_name {
215                format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
216            } else if effective_features.is_empty() {
217                format!("{dep_name} = \"{ver}\"")
218            } else {
219                format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
220            }
221        }
222        crate::config::DependencyMode::Local => {
223            if crate_name != dep_name {
224                format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
225            } else if effective_features.is_empty() {
226                format!("{dep_name} = {{ path = \"{crate_path}\" }}")
227            } else {
228                format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
229            }
230        }
231    };
232    // serde_json is needed either when args use json_object/handle, or when the
233    // mock server binary is present (it uses serde_json::Value for fixture bodies),
234    // or when http integration tests are generated (they serialize fixture bodies).
235    let effective_needs_serde_json = needs_serde_json || needs_mock_server || needs_http_tests;
236    let serde_line = if effective_needs_serde_json {
237        "\nserde_json = \"1\""
238    } else {
239        ""
240    };
241    // An empty `[workspace]` table makes the e2e crate its own workspace root, so
242    // it never gets pulled into a parent crate's workspace. This means consumers
243    // don't have to remember to add `e2e/rust` to `workspace.exclude`, and
244    // `cargo fmt`/`cargo build` work the same whether the parent has a
245    // workspace or not.
246    // Mock server requires axum (HTTP router) and tokio-stream (SSE streaming).
247    // The standalone binary additionally needs serde (derive) and walkdir.
248    // Http integration tests require axum-test for the test server.
249    let needs_axum = needs_mock_server || needs_http_tests;
250    let mock_lines = if needs_axum {
251        let mut lines = format!(
252            "\naxum = \"{axum}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
253            axum = tv::cargo::AXUM,
254            walkdir = tv::cargo::WALKDIR,
255        );
256        if needs_mock_server {
257            lines.push_str(&format!(
258                "\ntokio-stream = \"{tokio_stream}\"",
259                tokio_stream = tv::cargo::TOKIO_STREAM
260            ));
261        }
262        if needs_http_tests {
263            lines.push_str("\naxum-test = \"20\"\nbytes = \"1\"");
264        }
265        if needs_tower_http {
266            lines.push_str(&format!(
267                "\ntower-http = {{ version = \"{tower_http}\", features = [\"cors\", \"fs\"] }}\ntempfile = \"{tempfile}\"",
268                tower_http = tv::cargo::TOWER_HTTP,
269                tempfile = tv::cargo::TEMPFILE,
270            ));
271        }
272        lines
273    } else {
274        String::new()
275    };
276    let mut machete_ignored: Vec<&str> = Vec::new();
277    if effective_needs_serde_json {
278        machete_ignored.push("\"serde_json\"");
279    }
280    if needs_axum {
281        machete_ignored.push("\"axum\"");
282        machete_ignored.push("\"serde\"");
283        machete_ignored.push("\"walkdir\"");
284    }
285    if needs_mock_server {
286        machete_ignored.push("\"tokio-stream\"");
287    }
288    if needs_http_tests {
289        machete_ignored.push("\"axum-test\"");
290        machete_ignored.push("\"bytes\"");
291    }
292    if needs_tower_http {
293        machete_ignored.push("\"tower-http\"");
294        machete_ignored.push("\"tempfile\"");
295    }
296    let machete_section = if machete_ignored.is_empty() {
297        String::new()
298    } else {
299        format!(
300            "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
301            machete_ignored.join(", ")
302        )
303    };
304    let tokio_line = if needs_tokio {
305        "\ntokio = { version = \"1\", features = [\"full\"] }"
306    } else {
307        ""
308    };
309    let bin_section = if needs_mock_server || needs_http_tests {
310        "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
311    } else {
312        ""
313    };
314    let header = hash::header(CommentStyle::Hash);
315    format!(
316        r#"{header}
317[workspace]
318
319[package]
320name = "{e2e_name}"
321version = "0.1.0"
322edition = "2021"
323license = "MIT"
324publish = false
325{bin_section}
326[dependencies]
327{dep_spec}{serde_line}{mock_lines}{tokio_line}
328{machete_section}"#
329    )
330}
331
332fn render_test_file(
333    category: &str,
334    fixtures: &[&Fixture],
335    e2e_config: &E2eConfig,
336    dep_name: &str,
337    needs_mock_server: bool,
338) -> String {
339    let mut out = String::new();
340    out.push_str(&hash::header(CommentStyle::DoubleSlash));
341    let _ = writeln!(out, "//! E2e tests for category: {category}");
342    let _ = writeln!(out);
343
344    let module = resolve_module(e2e_config, dep_name);
345    let field_resolver = FieldResolver::new(
346        &e2e_config.fields,
347        &e2e_config.fields_optional,
348        &e2e_config.result_fields,
349        &e2e_config.fields_array,
350    );
351
352    // Check if this file has http-fixture tests (separate from call-based tests).
353    let file_has_http = fixtures.iter().any(|f| f.http.is_some());
354    // Call-based: has mock_response (liter-llm style), NOT pure stub fixtures.
355    // Pure stub fixtures (neither http nor mock_response) use a stub path — no function import.
356    let file_has_call_based = fixtures.iter().any(|f| f.mock_response.is_some());
357
358    // Collect all unique (module, function) pairs needed across call-based fixtures only.
359    // Http fixtures and stub fixtures use different code paths and don't import the call function.
360    if file_has_call_based {
361        let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
362        for fixture in fixtures.iter().filter(|f| f.mock_response.is_some()) {
363            let call_config = e2e_config.resolve_call(fixture.call.as_deref());
364            let fn_name = resolve_function_name_for_call(call_config);
365            let mod_name = resolve_module_for_call(call_config, dep_name);
366            imported.insert((mod_name, fn_name));
367        }
368        // Emit use statements, grouping by module when possible.
369        let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
370        for (mod_name, fn_name) in &imported {
371            by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
372        }
373        for (mod_name, fns) in &by_module {
374            if fns.len() == 1 {
375                let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
376            } else {
377                let joined = fns.join(", ");
378                let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
379            }
380        }
381    }
382
383    // Http fixtures use App + RequestContext for integration tests.
384    if file_has_http {
385        let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
386    }
387
388    // Import handle constructor functions and the config type they use.
389    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
390    if has_handle_args {
391        let _ = writeln!(out, "use {module}::CrawlConfig;");
392    }
393    for arg in &e2e_config.call.args {
394        if arg.arg_type == "handle" {
395            use heck::ToSnakeCase;
396            let constructor_name = format!("create_{}", arg.name.to_snake_case());
397            let _ = writeln!(out, "use {module}::{constructor_name};");
398        }
399    }
400
401    // Import mock_server module when any fixture in this file uses mock_response.
402    let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.mock_response.is_some());
403    if file_needs_mock {
404        let _ = writeln!(out, "mod mock_server;");
405        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
406    }
407
408    // Import the visitor trait, result enum, and node context when any fixture
409    // in this file declares a `visitor` block. Without these, the inline
410    // `impl HtmlVisitor for _TestVisitor` block fails to resolve.
411    let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
412    if file_needs_visitor {
413        let visitor_trait = resolve_visitor_trait(&module);
414        let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
415    }
416
417    let _ = writeln!(out);
418
419    for fixture in fixtures {
420        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
421        let _ = writeln!(out);
422    }
423
424    if !out.ends_with('\n') {
425        out.push('\n');
426    }
427    out
428}
429
430fn render_test_function(
431    out: &mut String,
432    fixture: &Fixture,
433    e2e_config: &E2eConfig,
434    dep_name: &str,
435    field_resolver: &FieldResolver,
436) {
437    // Http fixtures get their own integration test code path.
438    if fixture.http.is_some() {
439        render_http_test_function(out, fixture, dep_name);
440        return;
441    }
442
443    // Fixtures that have neither `http` nor `mock_response` are schema/spec
444    // validation fixtures (asyncapi, grpc, graphql_schema, etc.). These don't
445    // yet have a callable function in the Rust e2e suite — generate a stub
446    // that compiles and passes to preserve test count without breaking builds.
447    if fixture.http.is_none() && fixture.mock_response.is_none() {
448        let fn_name = sanitize_ident(&fixture.id);
449        let description = &fixture.description;
450        let _ = writeln!(out, "#[tokio::test]");
451        let _ = writeln!(out, "async fn test_{fn_name}() {{");
452        let _ = writeln!(out, "    // {description}");
453        let _ = writeln!(
454            out,
455            "    // TODO: implement when a callable API is available for this fixture type."
456        );
457        let _ = writeln!(out, "}}");
458        return;
459    }
460
461    let fn_name = sanitize_ident(&fixture.id);
462    let description = &fixture.description;
463    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
464    let function_name = resolve_function_name_for_call(call_config);
465    let module = resolve_module_for_call(call_config, dep_name);
466    let result_var = &call_config.result_var;
467    let has_mock = fixture.mock_response.is_some();
468
469    // Tests with a mock server are always async (Axum requires a Tokio runtime).
470    let is_async = call_config.r#async || has_mock;
471    if is_async {
472        let _ = writeln!(out, "#[tokio::test]");
473        let _ = writeln!(out, "async fn test_{fn_name}() {{");
474    } else {
475        let _ = writeln!(out, "#[test]");
476        let _ = writeln!(out, "fn test_{fn_name}() {{");
477    }
478    let _ = writeln!(out, "    // {description}");
479
480    // Emit mock server setup before building arguments so arg expressions can
481    // reference `mock_server.url` when needed.
482    if has_mock {
483        render_mock_server_setup(out, fixture, e2e_config);
484    }
485
486    // Check if any assertion is an error assertion.
487    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
488
489    // Resolve Rust-specific overrides for argument shaping.
490    let rust_overrides = call_config.overrides.get("rust");
491    let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
492    let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
493
494    // Emit input variable bindings from args config.
495    let mut arg_exprs: Vec<String> = Vec::new();
496    for arg in &call_config.args {
497        let value = resolve_field(&fixture.input, &arg.field);
498        let var_name = &arg.name;
499        let (bindings, expr) = render_rust_arg(
500            var_name,
501            value,
502            &arg.arg_type,
503            arg.optional,
504            &module,
505            &fixture.id,
506            if has_mock {
507                Some("mock_server.url.as_str()")
508            } else {
509                None
510            },
511            arg.owned,
512            arg.element_type.as_deref(),
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 wrap_options_in_some && arg.arg_type == "json_object" {
521            if let Some(rest) = expr.strip_prefix('&') {
522                format!("Some({rest}.clone())")
523            } else {
524                format!("Some({expr})")
525            }
526        } else {
527            expr
528        };
529        arg_exprs.push(final_expr);
530    }
531
532    // Emit visitor if present in fixture.
533    if let Some(visitor_spec) = &fixture.visitor {
534        let _ = writeln!(out, "    struct _TestVisitor;");
535        let _ = writeln!(out, "    impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
536        for (method_name, action) in &visitor_spec.callbacks {
537            emit_rust_visitor_method(out, method_name, action);
538        }
539        let _ = writeln!(out, "    }}");
540        let _ = writeln!(
541            out,
542            "    let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
543        );
544        arg_exprs.push("Some(visitor)".to_string());
545    } else {
546        // No fixture-supplied visitor: append any extra positional args declared in
547        // the rust override (e.g. trailing `None` for an Option<VisitorParam> slot).
548        arg_exprs.extend(extra_args);
549    }
550
551    let args_str = arg_exprs.join(", ");
552
553    let await_suffix = if is_async { ".await" } else { "" };
554
555    let result_is_tree = call_config.result_var == "tree";
556    // When the rust override sets result_is_simple, the function returns a plain type
557    // (String, Vec<T>, etc.) — field-access assertions use the result var directly.
558    let result_is_simple = rust_overrides.is_some_and(|o| o.result_is_simple);
559    // When result_is_vec is set, the function returns Vec<T>. Field-path assertions
560    // are wrapped in `.iter().all(|r| ...)` so every element is checked.
561    let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
562    // When result_is_option is set, the function returns Option<T>. Field-path
563    // assertions unwrap first via `.as_ref().expect("Option should be Some")`.
564    let result_is_option = rust_overrides.is_some_and(|o| o.result_is_option);
565
566    if has_error_assertion {
567        let _ = writeln!(out, "    let {result_var} = {function_name}({args_str}){await_suffix};");
568        // Render error assertions.
569        for assertion in &fixture.assertions {
570            render_assertion(
571                out,
572                assertion,
573                result_var,
574                &module,
575                dep_name,
576                true,
577                &[],
578                field_resolver,
579                result_is_tree,
580                result_is_simple,
581                false,
582                false,
583            );
584        }
585        let _ = writeln!(out, "}}");
586        return;
587    }
588
589    // Non-error path: unwrap the result.
590    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
591
592    // Check if any assertion actually uses the result variable.
593    // If all assertions are skipped (field not on result type), use `_` to avoid
594    // Rust's "variable never used" warning.
595    let has_usable_assertion = fixture.assertions.iter().any(|a| {
596        if a.assertion_type == "not_error" || a.assertion_type == "error" {
597            return false;
598        }
599        if a.assertion_type == "method_result" {
600            // method_result assertions that would generate only a TODO comment don't use the
601            // result variable. These are: missing `method` field, or unsupported `check` type.
602            let supported_checks = [
603                "equals",
604                "is_true",
605                "is_false",
606                "greater_than_or_equal",
607                "count_min",
608                "is_error",
609                "contains",
610                "not_empty",
611                "is_empty",
612            ];
613            let check = a.check.as_deref().unwrap_or("is_true");
614            if a.method.is_none() || !supported_checks.contains(&check) {
615                return false;
616            }
617        }
618        match &a.field {
619            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
620            _ => true,
621        }
622    });
623
624    let result_binding = if has_usable_assertion {
625        result_var.to_string()
626    } else {
627        "_".to_string()
628    };
629
630    // Detect Option-returning functions: only skip unwrap when ALL assertions are
631    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
632    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
633    let has_field_access = fixture
634        .assertions
635        .iter()
636        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
637    let only_emptiness_checks = !has_field_access
638        && fixture.assertions.iter().all(|a| {
639            matches!(
640                a.assertion_type.as_str(),
641                "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
642            )
643        });
644
645    // Per-rust override of the call-level `returns_result`. When set, takes
646    // precedence over `CallConfig.returns_result` for the Rust generator only.
647    let returns_result = rust_overrides
648        .and_then(|o| o.returns_result)
649        .unwrap_or(call_config.returns_result);
650
651    let unwrap_suffix = if returns_result {
652        ".expect(\"should succeed\")"
653    } else {
654        ""
655    };
656    if only_emptiness_checks || !returns_result {
657        // Option-returning or non-Result-returning: bind raw value, no unwrap.
658        let _ = writeln!(
659            out,
660            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
661        );
662    } else if has_not_error || !fixture.assertions.is_empty() {
663        let _ = writeln!(
664            out,
665            "    let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
666        );
667    } else {
668        let _ = writeln!(
669            out,
670            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
671        );
672    }
673
674    // Emit Option field unwrap bindings for any fields accessed in assertions.
675    // Use FieldResolver to handle optional fields, including nested/aliased paths.
676    // Skipped when the call returns Vec<T>: per-element iteration is emitted by
677    // `render_assertion` itself, so the call-site has no single result struct
678    // to unwrap fields off of.
679    let string_assertion_types = [
680        "equals",
681        "contains",
682        "contains_all",
683        "contains_any",
684        "not_contains",
685        "starts_with",
686        "ends_with",
687        "min_length",
688        "max_length",
689        "matches_regex",
690    ];
691    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
692    if !result_is_vec {
693        for assertion in &fixture.assertions {
694            if let Some(f) = &assertion.field {
695                if !f.is_empty()
696                    && string_assertion_types.contains(&assertion.assertion_type.as_str())
697                    && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
698                {
699                    // Only unwrap optional string fields — numeric optionals (u64, usize)
700                    // don't support .as_deref() and should be compared directly.
701                    let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
702                    if !is_string_assertion {
703                        continue;
704                    }
705                    if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
706                        let _ = writeln!(out, "    {binding}");
707                        unwrapped_fields.push((f.clone(), local_var));
708                    }
709                }
710            }
711        }
712    }
713
714    // Render assertions.
715    for assertion in &fixture.assertions {
716        if assertion.assertion_type == "not_error" {
717            // Already handled by .expect() above.
718            continue;
719        }
720        render_assertion(
721            out,
722            assertion,
723            result_var,
724            &module,
725            dep_name,
726            false,
727            &unwrapped_fields,
728            field_resolver,
729            result_is_tree,
730            result_is_simple,
731            result_is_vec,
732            result_is_option,
733        );
734    }
735
736    let _ = writeln!(out, "}}");
737}
738
739// ---------------------------------------------------------------------------
740// Argument rendering
741// ---------------------------------------------------------------------------
742
743#[allow(clippy::too_many_arguments)]
744fn render_rust_arg(
745    name: &str,
746    value: &serde_json::Value,
747    arg_type: &str,
748    optional: bool,
749    module: &str,
750    fixture_id: &str,
751    mock_base_url: Option<&str>,
752    owned: bool,
753    element_type: Option<&str>,
754) -> (Vec<String>, String) {
755    if arg_type == "mock_url" {
756        let lines = vec![format!(
757            "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
758        )];
759        return (lines, format!("&{name}"));
760    }
761    // When the arg is a base_url and a mock server is running, use the mock server URL.
762    if arg_type == "base_url" {
763        if let Some(url_expr) = mock_base_url {
764            return (vec![], url_expr.to_string());
765        }
766        // No mock server: fall through to string handling below.
767    }
768    if arg_type == "handle" {
769        // Generate a create_engine (or equivalent) call and pass the config.
770        // If the fixture has input.config, serialize it as a json_object and pass it;
771        // otherwise pass None.
772        use heck::ToSnakeCase;
773        let constructor_name = format!("create_{}", name.to_snake_case());
774        let mut lines = Vec::new();
775        if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
776            lines.push(format!(
777                "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
778            ));
779        } else {
780            // Serialize the config JSON and deserialize at runtime.
781            let json_literal = serde_json::to_string(value).unwrap_or_default();
782            let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
783            lines.push(format!(
784                "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
785            ));
786            lines.push(format!(
787                "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
788            ));
789        }
790        return (lines, format!("&{name}"));
791    }
792    if arg_type == "json_object" {
793        return render_json_object_arg(name, value, optional, owned, element_type, module);
794    }
795    if value.is_null() && !optional {
796        // Required arg with no fixture value: use a language-appropriate default.
797        let default_val = match arg_type {
798            "string" => "String::new()".to_string(),
799            "int" | "integer" => "0".to_string(),
800            "float" | "number" => "0.0_f64".to_string(),
801            "bool" | "boolean" => "false".to_string(),
802            _ => "Default::default()".to_string(),
803        };
804        // String args are passed by reference in Rust.
805        let expr = if arg_type == "string" {
806            format!("&{name}")
807        } else {
808            name.to_string()
809        };
810        return (vec![format!("let {name} = {default_val};")], expr);
811    }
812    let literal = json_to_rust_literal(value, arg_type);
813    // String args are raw string literals (`r#"..."#`) — already `&str`, no extra `&` needed.
814    // Bytes args are passed by reference using `.as_bytes()` in the `expr` closure below.
815    let pass_by_ref = arg_type == "bytes";
816    let optional_expr = |n: &str| {
817        if arg_type == "string" {
818            format!("{n}.as_deref()")
819        } else if arg_type == "bytes" {
820            format!("{n}.as_deref().map(|v| v.as_slice())")
821        } else {
822            // Owned numeric / bool / generic: pass the Option<T> by value.
823            // Function signature shape `Option<T>` matches without `.as_ref()`,
824            // which would produce `Option<&T>` and fail to coerce.
825            n.to_string()
826        }
827    };
828    let expr = |n: &str| {
829        if arg_type == "bytes" {
830            format!("{n}.as_bytes()")
831        } else if pass_by_ref {
832            format!("&{n}")
833        } else {
834            n.to_string()
835        }
836    };
837    if optional && value.is_null() {
838        let none_decl = match arg_type {
839            "string" => format!("let {name}: Option<String> = None;"),
840            "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
841            _ => format!("let {name} = None;"),
842        };
843        (vec![none_decl], optional_expr(name))
844    } else if optional {
845        (vec![format!("let {name} = Some({literal});")], optional_expr(name))
846    } else {
847        (vec![format!("let {name} = {literal};")], expr(name))
848    }
849}
850
851/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
852/// and deserialize it through serde at runtime. Type inference from the function signature
853/// determines the concrete type, keeping the generator generic.
854///
855/// `owned` — when true the binding is passed by value (no leading `&`); use for `Vec<T>` params.
856/// `element_type` — when set, emits `Vec<element_type>` annotation to satisfy type inference for
857///   `&[T]` parameters where `serde_json::from_value` cannot resolve the unsized slice type.
858fn render_json_object_arg(
859    name: &str,
860    value: &serde_json::Value,
861    optional: bool,
862    owned: bool,
863    element_type: Option<&str>,
864    _module: &str,
865) -> (Vec<String>, String) {
866    // Owned params (Vec<T>) are passed by value; ref params (most configs) use &.
867    let pass_by_ref = !owned;
868
869    if value.is_null() && optional {
870        // Use Default::default() — Rust functions take &T (or T for owned), not Option<T>.
871        let expr = if pass_by_ref {
872            format!("&{name}")
873        } else {
874            name.to_string()
875        };
876        return (vec![format!("let {name} = Default::default();")], expr);
877    }
878
879    // Fixture keys are camelCase; the Rust ConversionOptions type uses snake_case serde.
880    // Normalize keys before building the json! literal so deserialization succeeds.
881    let normalized = super::normalize_json_keys_to_snake_case(value);
882    // Build the json! macro invocation from the fixture object.
883    let json_literal = json_value_to_macro_literal(&normalized);
884    let mut lines = Vec::new();
885    lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
886
887    // When an explicit element type is given, annotate with Vec<T> so that
888    // serde_json::from_value can infer the element type for &[T] parameters (A4 fix).
889    let deser_expr = if let Some(elem) = element_type {
890        format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
891    } else {
892        format!("serde_json::from_value({name}_json).unwrap()")
893    };
894
895    // A1 fix: always deser as T (never wrap in Some()); optional non-null args target
896    // &T not &Option<T>. Pass as &T (ref) or T (owned) depending on the `owned` flag.
897    lines.push(format!("let {name} = {deser_expr};"));
898    let expr = if pass_by_ref {
899        format!("&{name}")
900    } else {
901        name.to_string()
902    };
903    (lines, expr)
904}
905
906/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
907fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
908    match value {
909        serde_json::Value::Null => "null".to_string(),
910        serde_json::Value::Bool(b) => format!("{b}"),
911        serde_json::Value::Number(n) => n.to_string(),
912        serde_json::Value::String(s) => {
913            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
914            format!("\"{escaped}\"")
915        }
916        serde_json::Value::Array(arr) => {
917            let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
918            format!("[{}]", items.join(", "))
919        }
920        serde_json::Value::Object(obj) => {
921            let entries: Vec<String> = obj
922                .iter()
923                .map(|(k, v)| {
924                    let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
925                    format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
926                })
927                .collect();
928            format!("{{{}}}", entries.join(", "))
929        }
930    }
931}
932
933fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
934    match value {
935        serde_json::Value::Null => "None".to_string(),
936        serde_json::Value::Bool(b) => format!("{b}"),
937        serde_json::Value::Number(n) => {
938            if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
939                if let Some(f) = n.as_f64() {
940                    return format!("{f}_f64");
941                }
942            }
943            n.to_string()
944        }
945        serde_json::Value::String(s) => rust_raw_string(s),
946        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
947            let json_str = serde_json::to_string(value).unwrap_or_default();
948            let literal = rust_raw_string(&json_str);
949            format!("serde_json::from_str({literal}).unwrap()")
950        }
951    }
952}
953
954// ---------------------------------------------------------------------------
955// Http integration test helpers
956// ---------------------------------------------------------------------------
957
958/// How to call a method on axum_test::TestServer in generated code.
959enum ServerCall<'a> {
960    /// Emit `server.get(path)` / `server.post(path)` etc.
961    Shorthand(&'a str),
962    /// Emit `server.method(axum::http::Method::OPTIONS, path)` etc.
963    AxumMethod(&'a str),
964}
965
966/// How to register a route on a spikard App in generated code.
967enum RouteRegistration<'a> {
968    /// Emit `spikard::get(path)` / `spikard::post(path)` etc.
969    Shorthand(&'a str),
970    /// Emit `spikard::RouteBuilder::new(spikard::Method::Options, path)` etc.
971    Explicit(&'a str),
972}
973
974/// Generate a complete integration test function for an http fixture.
975///
976/// Builds a real spikard `App` with a handler that returns the expected
977/// response, then uses `axum_test::TestServer` to send the request and
978/// assert the status code.
979fn render_http_test_function(out: &mut String, fixture: &Fixture, dep_name: &str) {
980    let http = match &fixture.http {
981        Some(h) => h,
982        None => return,
983    };
984
985    let fn_name = sanitize_ident(&fixture.id);
986    let description = &fixture.description;
987
988    let route = &http.handler.route;
989
990    // spikard provides convenience functions for GET/POST/PUT/PATCH/DELETE.
991    // All other methods (HEAD, OPTIONS, TRACE, etc.) must use RouteBuilder::new directly.
992    let route_reg = match http.handler.method.to_lowercase().as_str() {
993        "get" => RouteRegistration::Shorthand("get"),
994        "post" => RouteRegistration::Shorthand("post"),
995        "put" => RouteRegistration::Shorthand("put"),
996        "patch" => RouteRegistration::Shorthand("patch"),
997        "delete" => RouteRegistration::Shorthand("delete"),
998        "head" => RouteRegistration::Explicit("Head"),
999        "options" => RouteRegistration::Explicit("Options"),
1000        "trace" => RouteRegistration::Explicit("Trace"),
1001        _ => RouteRegistration::Shorthand("get"),
1002    };
1003
1004    // axum_test::TestServer has shorthand methods for GET/POST/PUT/PATCH/DELETE.
1005    // For HEAD and other methods, use server.method(axum::http::Method::HEAD, path).
1006    let server_call = match http.request.method.to_uppercase().as_str() {
1007        "GET" => ServerCall::Shorthand("get"),
1008        "POST" => ServerCall::Shorthand("post"),
1009        "PUT" => ServerCall::Shorthand("put"),
1010        "PATCH" => ServerCall::Shorthand("patch"),
1011        "DELETE" => ServerCall::Shorthand("delete"),
1012        "HEAD" => ServerCall::AxumMethod("HEAD"),
1013        "OPTIONS" => ServerCall::AxumMethod("OPTIONS"),
1014        "TRACE" => ServerCall::AxumMethod("TRACE"),
1015        _ => ServerCall::Shorthand("get"),
1016    };
1017
1018    let req_path = &http.request.path;
1019    let status = http.expected_response.status_code;
1020
1021    // Serialize expected response body (if any).
1022    let body_str = match &http.expected_response.body {
1023        Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1024        None => String::new(),
1025    };
1026    let body_literal = rust_raw_string(&body_str);
1027
1028    // Serialize request body (if any).
1029    let req_body_str = match &http.request.body {
1030        Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1031        None => String::new(),
1032    };
1033    let has_req_body = !req_body_str.is_empty();
1034
1035    // Extract middleware from handler (if any).
1036    let middleware = http.handler.middleware.as_ref();
1037    let cors_cfg: Option<&CorsConfig> = middleware.and_then(|m| m.cors.as_ref());
1038    let static_files_cfgs: Option<&Vec<StaticFilesConfig>> = middleware.and_then(|m| m.static_files.as_ref());
1039    let has_static_files = static_files_cfgs.is_some_and(|v| !v.is_empty());
1040
1041    let _ = writeln!(out, "#[tokio::test]");
1042    let _ = writeln!(out, "async fn test_{fn_name}() {{");
1043    let _ = writeln!(out, "    // {description}");
1044
1045    // When static-files middleware is configured, serve from a temp dir via ServeDir.
1046    if has_static_files {
1047        render_static_files_test(out, fixture, static_files_cfgs.unwrap(), &server_call, req_path, status);
1048        return;
1049    }
1050
1051    // Build handler that returns the expected response.
1052    let _ = writeln!(out, "    let expected_body = {body_literal}.to_string();");
1053    let _ = writeln!(out, "    let mut app = {dep_name}::App::new();");
1054
1055    // Emit route registration.
1056    match &route_reg {
1057        RouteRegistration::Shorthand(method) => {
1058            let _ = writeln!(
1059                out,
1060                "    app.route({dep_name}::{method}({route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1061            );
1062        }
1063        RouteRegistration::Explicit(variant) => {
1064            let _ = writeln!(
1065                out,
1066                "    app.route({dep_name}::RouteBuilder::new({dep_name}::Method::{variant}, {route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1067            );
1068        }
1069    }
1070    let _ = writeln!(out, "        let body = expected_body.clone();");
1071    let _ = writeln!(out, "        async move {{");
1072    let _ = writeln!(out, "            Ok(axum::http::Response::builder()");
1073    let _ = writeln!(out, "                .status({status}u16)");
1074    let _ = writeln!(out, "                .header(\"content-type\", \"application/json\")");
1075    let _ = writeln!(out, "                .body(axum::body::Body::from(body))");
1076    let _ = writeln!(out, "                .unwrap())");
1077    let _ = writeln!(out, "        }}");
1078    let _ = writeln!(out, "    }}).unwrap();");
1079
1080    // Build axum-test TestServer from the app router, optionally wrapping with CorsLayer.
1081    let _ = writeln!(out, "    let router = app.into_router().unwrap();");
1082    if let Some(cors) = cors_cfg {
1083        render_cors_layer(out, cors);
1084    }
1085    let _ = writeln!(out, "    let server = axum_test::TestServer::new(router);");
1086
1087    // Build and send the request.
1088    match &server_call {
1089        ServerCall::Shorthand(method) => {
1090            let _ = writeln!(out, "    let response = server.{method}({req_path:?})");
1091        }
1092        ServerCall::AxumMethod(method) => {
1093            let _ = writeln!(
1094                out,
1095                "    let response = server.method(axum::http::Method::{method}, {req_path:?})"
1096            );
1097        }
1098    }
1099
1100    // Add request headers (axum_test::TestRequest::add_header accepts &str via TryInto).
1101    for (name, value) in &http.request.headers {
1102        let n = rust_raw_string(name);
1103        let v = rust_raw_string(value);
1104        let _ = writeln!(out, "        .add_header({n}, {v})");
1105    }
1106
1107    // Add request body if present (pass as a JSON string so axum-test's bytes() API gets a Bytes value).
1108    if has_req_body {
1109        let req_body_literal = rust_raw_string(&req_body_str);
1110        let _ = writeln!(
1111            out,
1112            "        .bytes(bytes::Bytes::copy_from_slice({req_body_literal}.as_bytes()))"
1113        );
1114    }
1115
1116    let _ = writeln!(out, "        .await;");
1117
1118    // Assert status code.
1119    // When a CorsLayer is applied and the fixture expects a 2xx status, tower-http may
1120    // return 200 instead of 204 for preflight. Accept any 2xx status in that case.
1121    if cors_cfg.is_some() && (200..300).contains(&status) {
1122        let _ = writeln!(
1123            out,
1124            "    assert!(response.status_code().is_success(), \"expected CORS success status, got {{}}\", response.status_code());"
1125        );
1126    } else {
1127        let _ = writeln!(out, "    assert_eq!(response.status_code().as_u16(), {status}u16);");
1128    }
1129
1130    let _ = writeln!(out, "}}");
1131}
1132
1133/// Emit lines that wrap the axum router with a `tower_http::cors::CorsLayer`.
1134///
1135/// The CORS policy is derived from the fixture's `cors` middleware config.
1136/// After this function, `router` is reassigned to the layer-wrapped version.
1137fn render_cors_layer(out: &mut String, cors: &CorsConfig) {
1138    let _ = writeln!(
1139        out,
1140        "    // Apply CorsLayer from tower-http based on fixture CORS config."
1141    );
1142    let _ = writeln!(out, "    use tower_http::cors::CorsLayer;");
1143    let _ = writeln!(out, "    use axum::http::{{HeaderName, HeaderValue, Method}};");
1144    let _ = writeln!(out, "    let cors_layer = CorsLayer::new()");
1145
1146    // allow_origins
1147    if cors.allow_origins.is_empty() {
1148        let _ = writeln!(out, "        .allow_origin(tower_http::cors::Any)");
1149    } else {
1150        let _ = writeln!(out, "        .allow_origin([");
1151        for origin in &cors.allow_origins {
1152            let _ = writeln!(out, "            \"{origin}\".parse::<HeaderValue>().unwrap(),");
1153        }
1154        let _ = writeln!(out, "        ])");
1155    }
1156
1157    // allow_methods
1158    if cors.allow_methods.is_empty() {
1159        let _ = writeln!(out, "        .allow_methods(tower_http::cors::Any)");
1160    } else {
1161        let methods: Vec<String> = cors
1162            .allow_methods
1163            .iter()
1164            .map(|m| format!("Method::{}", m.to_uppercase()))
1165            .collect();
1166        let _ = writeln!(out, "        .allow_methods([{}])", methods.join(", "));
1167    }
1168
1169    // allow_headers
1170    if cors.allow_headers.is_empty() {
1171        let _ = writeln!(out, "        .allow_headers(tower_http::cors::Any)");
1172    } else {
1173        let headers: Vec<String> = cors
1174            .allow_headers
1175            .iter()
1176            .map(|h| {
1177                let lower = h.to_lowercase();
1178                match lower.as_str() {
1179                    "content-type" => "axum::http::header::CONTENT_TYPE".to_string(),
1180                    "authorization" => "axum::http::header::AUTHORIZATION".to_string(),
1181                    "accept" => "axum::http::header::ACCEPT".to_string(),
1182                    _ => format!("HeaderName::from_static(\"{lower}\")"),
1183                }
1184            })
1185            .collect();
1186        let _ = writeln!(out, "        .allow_headers([{}])", headers.join(", "));
1187    }
1188
1189    // max_age
1190    if let Some(secs) = cors.max_age {
1191        let _ = writeln!(out, "        .max_age(std::time::Duration::from_secs({secs}));");
1192    } else {
1193        let _ = writeln!(out, "        ;");
1194    }
1195
1196    let _ = writeln!(out, "    let router = router.layer(cors_layer);");
1197}
1198
1199/// Emit lines for a static-files integration test.
1200///
1201/// Writes fixture files to a temporary directory and serves them via
1202/// `tower_http::services::ServeDir`, bypassing the spikard App entirely.
1203fn render_static_files_test(
1204    out: &mut String,
1205    fixture: &Fixture,
1206    cfgs: &[StaticFilesConfig],
1207    server_call: &ServerCall<'_>,
1208    req_path: &str,
1209    status: u16,
1210) {
1211    let http = fixture.http.as_ref().unwrap();
1212
1213    let _ = writeln!(out, "    use tower_http::services::ServeDir;");
1214    let _ = writeln!(out, "    use axum::Router;");
1215    let _ = writeln!(out, "    let tmp_dir = tempfile::tempdir().expect(\"tmp dir\");");
1216
1217    // Build the router by nesting a ServeDir for each config entry.
1218    let _ = writeln!(out, "    let mut router = Router::new();");
1219    for cfg in cfgs {
1220        for file in &cfg.files {
1221            let file_path = file.path.replace('\\', "/");
1222            let content = rust_raw_string(&file.content);
1223            if file_path.contains('/') {
1224                let parent: String = file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
1225                let _ = writeln!(
1226                    out,
1227                    "    std::fs::create_dir_all(tmp_dir.path().join(\"{parent}\")).unwrap();"
1228                );
1229            }
1230            let _ = writeln!(
1231                out,
1232                "    std::fs::write(tmp_dir.path().join(\"{file_path}\"), {content}).unwrap();"
1233            );
1234        }
1235        let prefix = &cfg.route_prefix;
1236        let serve_dir_expr = if cfg.index_file {
1237            "ServeDir::new(tmp_dir.path()).append_index_html_on_directories(true)".to_string()
1238        } else {
1239            "ServeDir::new(tmp_dir.path())".to_string()
1240        };
1241        let _ = writeln!(out, "    router = router.nest_service({prefix:?}, {serve_dir_expr});");
1242    }
1243
1244    let _ = writeln!(out, "    let server = axum_test::TestServer::new(router);");
1245
1246    // Build and send the request.
1247    match server_call {
1248        ServerCall::Shorthand(method) => {
1249            let _ = writeln!(out, "    let response = server.{method}({req_path:?})");
1250        }
1251        ServerCall::AxumMethod(method) => {
1252            let _ = writeln!(
1253                out,
1254                "    let response = server.method(axum::http::Method::{method}, {req_path:?})"
1255            );
1256        }
1257    }
1258
1259    // Add request headers.
1260    for (name, value) in &http.request.headers {
1261        let n = rust_raw_string(name);
1262        let v = rust_raw_string(value);
1263        let _ = writeln!(out, "        .add_header({n}, {v})");
1264    }
1265
1266    let _ = writeln!(out, "        .await;");
1267    let _ = writeln!(out, "    assert_eq!(response.status_code().as_u16(), {status}u16);");
1268    let _ = writeln!(out, "}}");
1269}
1270
1271// ---------------------------------------------------------------------------
1272// Mock server helpers
1273// ---------------------------------------------------------------------------
1274
1275/// Emit mock server setup lines into a test function body.
1276///
1277/// Builds `MockRoute` objects from the fixture's `mock_response` and starts
1278/// the server.  The resulting `mock_server` variable is in scope for the rest
1279/// of the test function.
1280fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
1281    let mock = match fixture.mock_response.as_ref() {
1282        Some(m) => m,
1283        None => return,
1284    };
1285
1286    // Resolve the HTTP path and method from the call config.
1287    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1288    let path = call_config.path.as_deref().unwrap_or("/");
1289    let method = call_config.method.as_deref().unwrap_or("POST");
1290
1291    let status = mock.status;
1292
1293    // Render headers map as a Vec<(String, String)> literal for stable iteration order.
1294    let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
1295    header_entries.sort_by(|a, b| a.0.cmp(b.0));
1296    let render_headers = |out: &mut String| {
1297        let _ = writeln!(out, "        headers: vec![");
1298        for (name, value) in &header_entries {
1299            let n = rust_raw_string(name);
1300            let v = rust_raw_string(value);
1301            let _ = writeln!(out, "            ({n}.to_string(), {v}.to_string()),");
1302        }
1303        let _ = writeln!(out, "        ],");
1304    };
1305
1306    if let Some(chunks) = &mock.stream_chunks {
1307        // Streaming SSE response.
1308        let _ = writeln!(out, "    let mock_route = MockRoute {{");
1309        let _ = writeln!(out, "        path: \"{path}\",");
1310        let _ = writeln!(out, "        method: \"{method}\",");
1311        let _ = writeln!(out, "        status: {status},");
1312        let _ = writeln!(out, "        body: String::new(),");
1313        let _ = writeln!(out, "        stream_chunks: vec![");
1314        for chunk in chunks {
1315            let chunk_str = match chunk {
1316                serde_json::Value::String(s) => rust_raw_string(s),
1317                other => {
1318                    let s = serde_json::to_string(other).unwrap_or_default();
1319                    rust_raw_string(&s)
1320                }
1321            };
1322            let _ = writeln!(out, "            {chunk_str}.to_string(),");
1323        }
1324        let _ = writeln!(out, "        ],");
1325        render_headers(out);
1326        let _ = writeln!(out, "    }};");
1327    } else {
1328        // Non-streaming JSON response.
1329        let body_str = match &mock.body {
1330            Some(b) => {
1331                let s = serde_json::to_string(b).unwrap_or_default();
1332                rust_raw_string(&s)
1333            }
1334            None => rust_raw_string("{}"),
1335        };
1336        let _ = writeln!(out, "    let mock_route = MockRoute {{");
1337        let _ = writeln!(out, "        path: \"{path}\",");
1338        let _ = writeln!(out, "        method: \"{method}\",");
1339        let _ = writeln!(out, "        status: {status},");
1340        let _ = writeln!(out, "        body: {body_str}.to_string(),");
1341        let _ = writeln!(out, "        stream_chunks: vec![],");
1342        render_headers(out);
1343        let _ = writeln!(out, "    }};");
1344    }
1345
1346    let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
1347}
1348
1349/// Generate the complete `mock_server.rs` module source.
1350pub fn render_mock_server_module() -> String {
1351    // This is parameterized Axum mock server code identical in structure to
1352    // liter-llm's mock_server.rs but without any project-specific imports.
1353    hash::header(CommentStyle::DoubleSlash)
1354        + r#"//
1355// Minimal axum-based mock HTTP server for e2e tests.
1356
1357use std::net::SocketAddr;
1358use std::sync::Arc;
1359
1360use axum::Router;
1361use axum::body::Body;
1362use axum::extract::State;
1363use axum::http::{Request, StatusCode};
1364use axum::response::{IntoResponse, Response};
1365use tokio::net::TcpListener;
1366
1367/// A single mock route: match by path + method, return a configured response.
1368#[derive(Clone, Debug)]
1369pub struct MockRoute {
1370    /// URL path to match, e.g. `"/v1/chat/completions"`.
1371    pub path: &'static str,
1372    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
1373    pub method: &'static str,
1374    /// HTTP status code to return.
1375    pub status: u16,
1376    /// Response body JSON string (used when `stream_chunks` is empty).
1377    pub body: String,
1378    /// Ordered SSE data payloads for streaming responses.
1379    /// Each entry becomes `data: <chunk>\n\n` in the response.
1380    /// A final `data: [DONE]\n\n` is always appended.
1381    pub stream_chunks: Vec<String>,
1382    /// Response headers to apply (name, value) pairs.
1383    /// Multiple entries with the same name produce multiple header lines.
1384    pub headers: Vec<(String, String)>,
1385}
1386
1387struct ServerState {
1388    routes: Vec<MockRoute>,
1389}
1390
1391pub struct MockServer {
1392    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
1393    pub url: String,
1394    handle: tokio::task::JoinHandle<()>,
1395}
1396
1397impl MockServer {
1398    /// Start a mock server with the given routes.  Binds to a random port on
1399    /// localhost and returns immediately once the server is listening.
1400    pub async fn start(routes: Vec<MockRoute>) -> Self {
1401        let state = Arc::new(ServerState { routes });
1402
1403        let app = Router::new().fallback(handle_request).with_state(state);
1404
1405        let listener = TcpListener::bind("127.0.0.1:0")
1406            .await
1407            .expect("Failed to bind mock server port");
1408        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1409        let url = format!("http://{addr}");
1410
1411        let handle = tokio::spawn(async move {
1412            axum::serve(listener, app).await.expect("Mock server failed");
1413        });
1414
1415        MockServer { url, handle }
1416    }
1417
1418    /// Stop the mock server.
1419    pub fn shutdown(self) {
1420        self.handle.abort();
1421    }
1422}
1423
1424impl Drop for MockServer {
1425    fn drop(&mut self) {
1426        self.handle.abort();
1427    }
1428}
1429
1430async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1431    let path = req.uri().path().to_owned();
1432    let method = req.method().as_str().to_uppercase();
1433
1434    for route in &state.routes {
1435        if route.path == path && route.method.to_uppercase() == method {
1436            let status =
1437                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1438
1439            if !route.stream_chunks.is_empty() {
1440                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1441                let mut sse = String::new();
1442                for chunk in &route.stream_chunks {
1443                    sse.push_str("data: ");
1444                    sse.push_str(chunk);
1445                    sse.push_str("\n\n");
1446                }
1447                sse.push_str("data: [DONE]\n\n");
1448
1449                let mut builder = Response::builder()
1450                    .status(status)
1451                    .header("content-type", "text/event-stream")
1452                    .header("cache-control", "no-cache");
1453                for (name, value) in &route.headers {
1454                    builder = builder.header(name, value);
1455                }
1456                return builder.body(Body::from(sse)).unwrap().into_response();
1457            }
1458
1459            let mut builder =
1460                Response::builder().status(status).header("content-type", "application/json");
1461            for (name, value) in &route.headers {
1462                builder = builder.header(name, value);
1463            }
1464            return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1465        }
1466    }
1467
1468    // No matching route → 404.
1469    Response::builder()
1470        .status(StatusCode::NOT_FOUND)
1471        .body(Body::from(format!("No mock route for {method} {path}")))
1472        .unwrap()
1473        .into_response()
1474}
1475"#
1476}
1477
1478/// Generate the `src/main.rs` for the standalone mock server binary.
1479///
1480/// The binary:
1481/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
1482/// - For each fixture that has a `mock_response` field, registers a route at
1483///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
1484/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
1485///   to stdout, then waits until stdin is closed for clean teardown.
1486///
1487/// This binary is intended for cross-language e2e suites (WASM, Node) that
1488/// spawn it as a child process and read the URL from its stdout.
1489pub fn render_mock_server_binary() -> String {
1490    hash::header(CommentStyle::DoubleSlash)
1491        + r#"//
1492// Standalone mock HTTP server binary for cross-language e2e tests.
1493// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1494//
1495// Usage: mock-server [fixtures-dir]
1496//   fixtures-dir defaults to "../../fixtures"
1497//
1498// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1499// then blocks until stdin is closed (parent process exit triggers cleanup).
1500
1501use std::collections::HashMap;
1502use std::io::{self, BufRead};
1503use std::net::SocketAddr;
1504use std::path::Path;
1505use std::sync::Arc;
1506
1507use axum::Router;
1508use axum::body::Body;
1509use axum::extract::State;
1510use axum::http::{Request, StatusCode};
1511use axum::response::{IntoResponse, Response};
1512use serde::Deserialize;
1513use tokio::net::TcpListener;
1514
1515// ---------------------------------------------------------------------------
1516// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1517// Supports both schemas:
1518//   liter-llm: mock_response: { status, body, stream_chunks }
1519//   spikard:   http.expected_response: { status_code, body, headers }
1520// ---------------------------------------------------------------------------
1521
1522#[derive(Debug, Deserialize)]
1523struct MockResponse {
1524    status: u16,
1525    #[serde(default)]
1526    body: Option<serde_json::Value>,
1527    #[serde(default)]
1528    stream_chunks: Option<Vec<serde_json::Value>>,
1529    #[serde(default)]
1530    headers: HashMap<String, String>,
1531}
1532
1533#[derive(Debug, Deserialize)]
1534struct HttpExpectedResponse {
1535    status_code: u16,
1536    #[serde(default)]
1537    body: Option<serde_json::Value>,
1538    #[serde(default)]
1539    headers: HashMap<String, String>,
1540}
1541
1542#[derive(Debug, Deserialize)]
1543struct HttpFixture {
1544    expected_response: HttpExpectedResponse,
1545}
1546
1547#[derive(Debug, Deserialize)]
1548struct Fixture {
1549    id: String,
1550    #[serde(default)]
1551    mock_response: Option<MockResponse>,
1552    #[serde(default)]
1553    http: Option<HttpFixture>,
1554}
1555
1556impl Fixture {
1557    /// Bridge both schemas into a unified MockResponse.
1558    fn as_mock_response(&self) -> Option<MockResponse> {
1559        if let Some(mock) = &self.mock_response {
1560            return Some(MockResponse {
1561                status: mock.status,
1562                body: mock.body.clone(),
1563                stream_chunks: mock.stream_chunks.clone(),
1564                headers: mock.headers.clone(),
1565            });
1566        }
1567        if let Some(http) = &self.http {
1568            return Some(MockResponse {
1569                status: http.expected_response.status_code,
1570                body: http.expected_response.body.clone(),
1571                stream_chunks: None,
1572                headers: http.expected_response.headers.clone(),
1573            });
1574        }
1575        None
1576    }
1577}
1578
1579// ---------------------------------------------------------------------------
1580// Route table
1581// ---------------------------------------------------------------------------
1582
1583#[derive(Clone, Debug)]
1584struct MockRoute {
1585    status: u16,
1586    body: String,
1587    stream_chunks: Vec<String>,
1588    headers: Vec<(String, String)>,
1589}
1590
1591type RouteTable = Arc<HashMap<String, MockRoute>>;
1592
1593// ---------------------------------------------------------------------------
1594// Axum handler
1595// ---------------------------------------------------------------------------
1596
1597async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1598    let path = req.uri().path().to_owned();
1599
1600    // Try exact match first
1601    if let Some(route) = routes.get(&path) {
1602        return serve_route(route);
1603    }
1604
1605    // Try prefix match: find a route that is a prefix of the request path
1606    // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1607    for (route_path, route) in routes.iter() {
1608        if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1609            return serve_route(route);
1610        }
1611    }
1612
1613    Response::builder()
1614        .status(StatusCode::NOT_FOUND)
1615        .body(Body::from(format!("No mock route for {path}")))
1616        .unwrap()
1617        .into_response()
1618}
1619
1620fn serve_route(route: &MockRoute) -> Response {
1621    let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1622
1623    if !route.stream_chunks.is_empty() {
1624        let mut sse = String::new();
1625        for chunk in &route.stream_chunks {
1626            sse.push_str("data: ");
1627            sse.push_str(chunk);
1628            sse.push_str("\n\n");
1629        }
1630        sse.push_str("data: [DONE]\n\n");
1631
1632        let mut builder = Response::builder()
1633            .status(status)
1634            .header("content-type", "text/event-stream")
1635            .header("cache-control", "no-cache");
1636        for (name, value) in &route.headers {
1637            builder = builder.header(name, value);
1638        }
1639        return builder.body(Body::from(sse)).unwrap().into_response();
1640    }
1641
1642    // Only set the default content-type if the fixture does not override it.
1643    // Use application/json when the body looks like JSON (starts with { or [),
1644    // otherwise fall back to text/plain to avoid clients failing JSON-decode.
1645    let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
1646    let mut builder = Response::builder().status(status);
1647    if !has_content_type {
1648        let trimmed = route.body.trim_start();
1649        let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
1650            "application/json"
1651        } else {
1652            "text/plain"
1653        };
1654        builder = builder.header("content-type", default_ct);
1655    }
1656    for (name, value) in &route.headers {
1657        // Skip content-encoding headers — the mock server returns uncompressed bodies.
1658        // Sending a content-encoding without actually encoding the body would cause
1659        // clients to fail decompression.
1660        if name.to_lowercase() == "content-encoding" {
1661            continue;
1662        }
1663        // The <<absent>> sentinel means this header must NOT be present in the
1664        // real server response — do not emit it from the mock server either.
1665        if value == "<<absent>>" {
1666            continue;
1667        }
1668        // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
1669        // assert the header value matches the UUID pattern.
1670        if value == "<<uuid>>" {
1671            let uuid = format!(
1672                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
1673                rand_u32(),
1674                rand_u16(),
1675                rand_u16() & 0x0fff,
1676                (rand_u16() & 0x3fff) | 0x8000,
1677                rand_u48(),
1678            );
1679            builder = builder.header(name, uuid);
1680            continue;
1681        }
1682        builder = builder.header(name, value);
1683    }
1684    builder.body(Body::from(route.body.clone())).unwrap().into_response()
1685}
1686
1687/// Generate a pseudo-random u32 using the current time nanoseconds.
1688fn rand_u32() -> u32 {
1689    use std::time::{SystemTime, UNIX_EPOCH};
1690    let ns = SystemTime::now()
1691        .duration_since(UNIX_EPOCH)
1692        .map(|d| d.subsec_nanos())
1693        .unwrap_or(0);
1694    ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
1695}
1696
1697fn rand_u16() -> u16 {
1698    (rand_u32() & 0xffff) as u16
1699}
1700
1701fn rand_u48() -> u64 {
1702    ((rand_u32() as u64) << 16) | (rand_u16() as u64)
1703}
1704
1705// ---------------------------------------------------------------------------
1706// Fixture loading
1707// ---------------------------------------------------------------------------
1708
1709fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1710    let mut routes = HashMap::new();
1711    load_routes_recursive(fixtures_dir, &mut routes);
1712    routes
1713}
1714
1715fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1716    let entries = match std::fs::read_dir(dir) {
1717        Ok(e) => e,
1718        Err(err) => {
1719            eprintln!("warning: cannot read directory {}: {err}", dir.display());
1720            return;
1721        }
1722    };
1723
1724    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1725    paths.sort();
1726
1727    for path in paths {
1728        if path.is_dir() {
1729            load_routes_recursive(&path, routes);
1730        } else if path.extension().is_some_and(|ext| ext == "json") {
1731            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1732            if filename == "schema.json" || filename.starts_with('_') {
1733                continue;
1734            }
1735            let content = match std::fs::read_to_string(&path) {
1736                Ok(c) => c,
1737                Err(err) => {
1738                    eprintln!("warning: cannot read {}: {err}", path.display());
1739                    continue;
1740                }
1741            };
1742            let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1743                match serde_json::from_str(&content) {
1744                    Ok(v) => v,
1745                    Err(err) => {
1746                        eprintln!("warning: cannot parse {}: {err}", path.display());
1747                        continue;
1748                    }
1749                }
1750            } else {
1751                match serde_json::from_str::<Fixture>(&content) {
1752                    Ok(f) => vec![f],
1753                    Err(err) => {
1754                        eprintln!("warning: cannot parse {}: {err}", path.display());
1755                        continue;
1756                    }
1757                }
1758            };
1759
1760            for fixture in fixtures {
1761                if let Some(mock) = fixture.as_mock_response() {
1762                    let route_path = format!("/fixtures/{}", fixture.id);
1763                    let body = mock
1764                        .body
1765                        .as_ref()
1766                        .map(|b| match b {
1767                            // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
1768                            // fixtures. Return the raw value so clients receive the string itself,
1769                            // not its JSON-encoded form with extra surrounding quotes.
1770                            serde_json::Value::String(s) => s.clone(),
1771                            other => serde_json::to_string(other).unwrap_or_default(),
1772                        })
1773                        .unwrap_or_default();
1774                    let stream_chunks = mock
1775                        .stream_chunks
1776                        .unwrap_or_default()
1777                        .into_iter()
1778                        .map(|c| match c {
1779                            serde_json::Value::String(s) => s,
1780                            other => serde_json::to_string(&other).unwrap_or_default(),
1781                        })
1782                        .collect();
1783                    let mut headers: Vec<(String, String)> =
1784                        mock.headers.into_iter().collect();
1785                    headers.sort_by(|a, b| a.0.cmp(&b.0));
1786                    routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
1787                }
1788            }
1789        }
1790    }
1791}
1792
1793// ---------------------------------------------------------------------------
1794// Entry point
1795// ---------------------------------------------------------------------------
1796
1797#[tokio::main]
1798async fn main() {
1799    let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1800    let fixtures_dir = Path::new(&fixtures_dir_arg);
1801
1802    let routes = load_routes(fixtures_dir);
1803    eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1804
1805    let route_table: RouteTable = Arc::new(routes);
1806    let app = Router::new().fallback(handle_request).with_state(route_table);
1807
1808    let listener = TcpListener::bind("127.0.0.1:0")
1809        .await
1810        .expect("mock-server: failed to bind port");
1811    let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1812
1813    // Print the URL so the parent process can read it.
1814    println!("MOCK_SERVER_URL=http://{addr}");
1815    // Flush stdout explicitly so the parent does not block waiting.
1816    use std::io::Write;
1817    std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1818
1819    // Spawn the server in the background.
1820    tokio::spawn(async move {
1821        axum::serve(listener, app).await.expect("mock-server: server error");
1822    });
1823
1824    // Block until stdin is closed — the parent process controls lifetime.
1825    let stdin = io::stdin();
1826    let mut lines = stdin.lock().lines();
1827    while lines.next().is_some() {}
1828}
1829"#
1830}
1831
1832// ---------------------------------------------------------------------------
1833// Assertion rendering
1834// ---------------------------------------------------------------------------
1835
1836#[allow(clippy::too_many_arguments)]
1837fn render_assertion(
1838    out: &mut String,
1839    assertion: &Assertion,
1840    result_var: &str,
1841    module: &str,
1842    dep_name: &str,
1843    is_error_context: bool,
1844    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
1845    field_resolver: &FieldResolver,
1846    result_is_tree: bool,
1847    result_is_simple: bool,
1848    result_is_vec: bool,
1849    result_is_option: bool,
1850) {
1851    // Vec<T> result: iterate per-element so each assertion checks every element.
1852    // Field-path assertions become `for r in &{result} { <assert using r> }`.
1853    // Length-style assertions on the Vec itself (no field path) operate on the
1854    // Vec directly.
1855    let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
1856    if result_is_vec && has_field && !is_error_context {
1857        let _ = writeln!(out, "    for r in &{result_var} {{");
1858        render_assertion(
1859            out,
1860            assertion,
1861            "r",
1862            module,
1863            dep_name,
1864            is_error_context,
1865            unwrapped_fields,
1866            field_resolver,
1867            result_is_tree,
1868            result_is_simple,
1869            false, // already inside loop
1870            result_is_option,
1871        );
1872        let _ = writeln!(out, "    }}");
1873        return;
1874    }
1875    // Option<T> result: map `is_empty`/`not_empty` to `is_none()`/`is_some()`,
1876    // and unwrap the inner value before any other assertion runs.
1877    if result_is_option && !is_error_context {
1878        let assertion_type = assertion.assertion_type.as_str();
1879        if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
1880            let check = if assertion_type == "is_empty" {
1881                "is_none"
1882            } else {
1883                "is_some"
1884            };
1885            let _ = writeln!(
1886                out,
1887                "    assert!({result_var}.{check}(), \"expected Option to be {check}\");"
1888            );
1889            return;
1890        }
1891        // For any other assertion shape, unwrap the Option and recurse with a
1892        // bare reference variable so the rest of the renderer treats the inner
1893        // value as the result.
1894        let _ = writeln!(
1895            out,
1896            "    let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
1897        );
1898        render_assertion(
1899            out,
1900            assertion,
1901            "r",
1902            module,
1903            dep_name,
1904            is_error_context,
1905            unwrapped_fields,
1906            field_resolver,
1907            result_is_tree,
1908            result_is_simple,
1909            result_is_vec,
1910            false, // already unwrapped
1911        );
1912        return;
1913    }
1914    let _ = dep_name;
1915    // Handle synthetic fields like chunks_have_content (derived assertions).
1916    // These are computed expressions, not real struct fields — intercept before
1917    // the is_valid_for_result check so they are never treated as field accesses.
1918    if let Some(f) = &assertion.field {
1919        match f.as_str() {
1920            "chunks_have_content" => {
1921                match assertion.assertion_type.as_str() {
1922                    "is_true" => {
1923                        let _ = writeln!(
1924                            out,
1925                            "    assert!({result_var}.chunks.as_ref().is_some_and(|chunks| !chunks.is_empty() && chunks.iter().all(|c| !c.content.is_empty())), \"expected all chunks to have content\");"
1926                        );
1927                    }
1928                    "is_false" => {
1929                        let _ = writeln!(
1930                            out,
1931                            "    assert!({result_var}.chunks.as_ref().is_none() || {result_var}.chunks.as_ref().unwrap().iter().any(|c| c.content.is_empty()), \"expected some chunks to be empty\");"
1932                        );
1933                    }
1934                    _ => {
1935                        let _ = writeln!(
1936                            out,
1937                            "    // unsupported assertion type on synthetic field chunks_have_content"
1938                        );
1939                    }
1940                }
1941                return;
1942            }
1943            "chunks_have_embeddings" => {
1944                match assertion.assertion_type.as_str() {
1945                    "is_true" => {
1946                        let _ = writeln!(
1947                            out,
1948                            "    assert!({result_var}.chunks.as_ref().is_some_and(|c| c.iter().all(|ch| ch.embedding.as_ref().is_some_and(|e| !e.is_empty()))), \"expected all chunks to have embeddings\");"
1949                        );
1950                    }
1951                    "is_false" => {
1952                        let _ = writeln!(
1953                            out,
1954                            "    assert!({result_var}.chunks.as_ref().is_none_or(|c| c.iter().any(|ch| ch.embedding.as_ref().is_none_or(|e| e.is_empty()))), \"expected some chunks to lack embeddings\");"
1955                        );
1956                    }
1957                    _ => {
1958                        let _ = writeln!(
1959                            out,
1960                            "    // unsupported assertion type on synthetic field chunks_have_embeddings"
1961                        );
1962                    }
1963                }
1964                return;
1965            }
1966            // ---- EmbedResponse virtual fields ----
1967            // embed_texts returns Vec<Vec<f32>> in Rust (no wrapper struct).
1968            // result_var is the embedding matrix — use it directly.
1969            "embeddings" => {
1970                // "embeddings" as a field in count_equals/count_min means the outer list.
1971                // embed_texts returns Vec<Vec<f32>> directly; result_var IS the embedding matrix.
1972                let embed_list = result_var.to_string();
1973                match assertion.assertion_type.as_str() {
1974                    "count_equals" => {
1975                        if let Some(val) = &assertion.value {
1976                            if let Some(n) = val.as_u64() {
1977                                let _ = writeln!(
1978                                    out,
1979                                    "    assert_eq!({embed_list}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {embed_list}.len());"
1980                                );
1981                            }
1982                        }
1983                    }
1984                    "count_min" => {
1985                        if let Some(val) = &assertion.value {
1986                            if let Some(n) = val.as_u64() {
1987                                if n <= 1 {
1988                                    let _ =
1989                                        writeln!(out, "    assert!(!{embed_list}.is_empty(), \"expected >= {n}\");");
1990                                } else {
1991                                    let _ = writeln!(
1992                                        out,
1993                                        "    assert!({embed_list}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {embed_list}.len());"
1994                                    );
1995                                }
1996                            }
1997                        }
1998                    }
1999                    "not_empty" => {
2000                        let _ = writeln!(
2001                            out,
2002                            "    assert!(!{embed_list}.is_empty(), \"expected non-empty embeddings\");"
2003                        );
2004                    }
2005                    "is_empty" => {
2006                        let _ = writeln!(
2007                            out,
2008                            "    assert!({embed_list}.is_empty(), \"expected empty embeddings\");"
2009                        );
2010                    }
2011                    _ => {
2012                        let _ = writeln!(
2013                            out,
2014                            "    // skipped: unsupported assertion type on synthetic field 'embeddings'"
2015                        );
2016                    }
2017                }
2018                return;
2019            }
2020            "embedding_dimensions" => {
2021                let embed_list = result_var;
2022                let expr = format!("{embed_list}.first().map_or(0, |e| e.len())");
2023                match assertion.assertion_type.as_str() {
2024                    "equals" => {
2025                        if let Some(val) = &assertion.value {
2026                            let lit = numeric_literal(val);
2027                            let _ = writeln!(
2028                                out,
2029                                "    assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2030                            );
2031                        }
2032                    }
2033                    "greater_than" => {
2034                        if let Some(val) = &assertion.value {
2035                            let lit = numeric_literal(val);
2036                            let _ = writeln!(out, "    assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2037                        }
2038                    }
2039                    _ => {
2040                        let _ = writeln!(
2041                            out,
2042                            "    // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
2043                        );
2044                    }
2045                }
2046                return;
2047            }
2048            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2049                let embed_list = result_var;
2050                let pred = match f.as_str() {
2051                    "embeddings_valid" => {
2052                        format!("{embed_list}.iter().all(|e| !e.is_empty())")
2053                    }
2054                    "embeddings_finite" => {
2055                        format!("{embed_list}.iter().all(|e| e.iter().all(|v| v.is_finite()))")
2056                    }
2057                    "embeddings_non_zero" => {
2058                        format!("{embed_list}.iter().all(|e| e.iter().any(|v| *v != 0.0_f32))")
2059                    }
2060                    "embeddings_normalized" => {
2061                        format!(
2062                            "{embed_list}.iter().all(|e| {{ let n: f64 = e.iter().map(|v| f64::from(*v) * f64::from(*v)).sum(); (n - 1.0_f64).abs() < 1e-3 }})"
2063                        )
2064                    }
2065                    _ => unreachable!(),
2066                };
2067                match assertion.assertion_type.as_str() {
2068                    "is_true" => {
2069                        let _ = writeln!(out, "    assert!({pred}, \"expected true\");");
2070                    }
2071                    "is_false" => {
2072                        let _ = writeln!(out, "    assert!(!({pred}), \"expected false\");");
2073                    }
2074                    _ => {
2075                        let _ = writeln!(
2076                            out,
2077                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
2078                        );
2079                    }
2080                }
2081                return;
2082            }
2083            // ---- keywords / keywords_count ----
2084            // ExtractionResult has `extracted_keywords: Option<Vec<Keyword>>`.
2085            // Translate fixture virtual fields to that real accessor.
2086            "keywords" => {
2087                let accessor = format!("{result_var}.extracted_keywords");
2088                match assertion.assertion_type.as_str() {
2089                    "not_empty" => {
2090                        let _ = writeln!(
2091                            out,
2092                            "    assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected keywords to be present and non-empty\");"
2093                        );
2094                    }
2095                    "is_empty" => {
2096                        let _ = writeln!(
2097                            out,
2098                            "    assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected keywords to be empty or absent\");"
2099                        );
2100                    }
2101                    "count_min" => {
2102                        if let Some(val) = &assertion.value {
2103                            if let Some(n) = val.as_u64() {
2104                                if n <= 1 {
2105                                    let _ = writeln!(
2106                                        out,
2107                                        "    assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2108                                    );
2109                                } else {
2110                                    let _ = writeln!(
2111                                        out,
2112                                        "    assert!({accessor}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} keywords\");"
2113                                    );
2114                                }
2115                            }
2116                        }
2117                    }
2118                    "count_equals" => {
2119                        if let Some(val) = &assertion.value {
2120                            if let Some(n) = val.as_u64() {
2121                                let _ = writeln!(
2122                                    out,
2123                                    "    assert!({accessor}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} keywords\");"
2124                                );
2125                            }
2126                        }
2127                    }
2128                    _ => {
2129                        let _ = writeln!(
2130                            out,
2131                            "    // skipped: unsupported assertion type on synthetic field 'keywords'"
2132                        );
2133                    }
2134                }
2135                return;
2136            }
2137            "keywords_count" => {
2138                let expr = format!("{result_var}.extracted_keywords.as_ref().map_or(0, |v| v.len())");
2139                match assertion.assertion_type.as_str() {
2140                    "equals" => {
2141                        if let Some(val) = &assertion.value {
2142                            let lit = numeric_literal(val);
2143                            let _ = writeln!(
2144                                out,
2145                                "    assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2146                            );
2147                        }
2148                    }
2149                    "less_than_or_equal" => {
2150                        if let Some(val) = &assertion.value {
2151                            let lit = numeric_literal(val);
2152                            let _ = writeln!(out, "    assert!({expr} <= {lit} as usize, \"expected <= {lit}\");");
2153                        }
2154                    }
2155                    "greater_than_or_equal" => {
2156                        if let Some(val) = &assertion.value {
2157                            let lit = numeric_literal(val);
2158                            let _ = writeln!(out, "    assert!({expr} >= {lit} as usize, \"expected >= {lit}\");");
2159                        }
2160                    }
2161                    "greater_than" => {
2162                        if let Some(val) = &assertion.value {
2163                            let lit = numeric_literal(val);
2164                            let _ = writeln!(out, "    assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2165                        }
2166                    }
2167                    "less_than" => {
2168                        if let Some(val) = &assertion.value {
2169                            let lit = numeric_literal(val);
2170                            let _ = writeln!(out, "    assert!({expr} < {lit} as usize, \"expected < {lit}\");");
2171                        }
2172                    }
2173                    _ => {
2174                        let _ = writeln!(
2175                            out,
2176                            "    // skipped: unsupported assertion type on synthetic field 'keywords_count'"
2177                        );
2178                    }
2179                }
2180                return;
2181            }
2182            _ => {}
2183        }
2184    }
2185
2186    // Skip assertions on fields that don't exist on the result type.
2187    if let Some(f) = &assertion.field {
2188        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2189            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
2190            return;
2191        }
2192    }
2193
2194    // Determine field access expression:
2195    // 1. If the field was unwrapped to a local var, use that local var name.
2196    // 2. When result_is_simple, the function returns a plain type (String etc.) — use result_var.
2197    // 3. When the field path is exactly the result var name (sentinel: `field: "result"`),
2198    //    refer to the result variable directly to avoid emitting `result.result`.
2199    // 4. When the result is a Tree, map pseudo-field names to correct Rust expressions.
2200    // 5. Otherwise, use the field resolver to generate the accessor.
2201    let field_access = match &assertion.field {
2202        Some(f) if !f.is_empty() => {
2203            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
2204                local_var.clone()
2205            } else if result_is_simple {
2206                // Plain return type (String, Vec<T>, etc.) has no struct fields.
2207                // Use the result variable directly so assertions operate on the value itself.
2208                result_var.to_string()
2209            } else if f == result_var {
2210                // Sentinel: fixture uses `field: "result"` (or matches the result variable name)
2211                // to refer to the whole return value, not a struct field named "result".
2212                result_var.to_string()
2213            } else if result_is_tree {
2214                // Tree is an opaque type — its "fields" are accessed via root_node() or
2215                // free functions. Map known pseudo-field names to correct Rust expressions.
2216                tree_field_access_expr(f, result_var, module)
2217            } else {
2218                field_resolver.accessor(f, "rust", result_var)
2219            }
2220        }
2221        _ => result_var.to_string(),
2222    };
2223
2224    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
2225    let is_unwrapped = assertion
2226        .field
2227        .as_ref()
2228        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
2229
2230    match assertion.assertion_type.as_str() {
2231        "error" => {
2232            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
2233            if let Some(serde_json::Value::String(msg)) = &assertion.value {
2234                let escaped = escape_rust(msg);
2235                let _ = writeln!(
2236                    out,
2237                    "    assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
2238                );
2239            }
2240        }
2241        "not_error" => {
2242            // Handled at call site; nothing extra needed here.
2243        }
2244        "equals" => {
2245            if let Some(val) = &assertion.value {
2246                let expected = value_to_rust_string(val);
2247                if is_error_context {
2248                    return;
2249                }
2250                // For string equality, trim trailing whitespace to handle trailing newlines
2251                // from the converter.
2252                if val.is_string() {
2253                    // When the field is Optional<String> and was NOT pre-unwrapped to a local
2254                    // var (e.g. inside a result_is_vec iteration where the call-site unwrap
2255                    // pass is skipped), emit `.as_deref().unwrap_or("").trim()` so the
2256                    // expression is `&str` rather than `Option<String>`.
2257                    let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
2258                        let resolved = field_resolver.resolve(f);
2259                        let is_opt = field_resolver.is_optional(resolved);
2260                        let is_arr = field_resolver.is_array(resolved);
2261                        is_opt && !is_arr && !is_unwrapped
2262                    });
2263                    let field_expr = if is_opt_str_not_unwrapped {
2264                        format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
2265                    } else {
2266                        format!("{field_access}.trim()")
2267                    };
2268                    let _ = writeln!(
2269                        out,
2270                        "    assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
2271                    );
2272                } else if val.is_boolean() {
2273                    // Use assert!/assert!(!...) for booleans — clippy prefers this over assert_eq!(_, true/false).
2274                    if val.as_bool() == Some(true) {
2275                        let _ = writeln!(out, "    assert!({field_access}, \"equals assertion failed\");");
2276                    } else {
2277                        let _ = writeln!(out, "    assert!(!{field_access}, \"equals assertion failed\");");
2278                    }
2279                } else {
2280                    // Wrap expected value in Some() for optional fields.
2281                    let is_opt = assertion.field.as_ref().is_some_and(|f| {
2282                        let resolved = field_resolver.resolve(f);
2283                        field_resolver.is_optional(resolved)
2284                    });
2285                    if is_opt
2286                        && !unwrapped_fields
2287                            .iter()
2288                            .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
2289                    {
2290                        let _ = writeln!(
2291                            out,
2292                            "    assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
2293                        );
2294                    } else {
2295                        let _ = writeln!(
2296                            out,
2297                            "    assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
2298                        );
2299                    }
2300                }
2301            }
2302        }
2303        "contains" => {
2304            if let Some(val) = &assertion.value {
2305                let expected = value_to_rust_string(val);
2306                let line = format!(
2307                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2308                );
2309                let _ = writeln!(out, "{line}");
2310            }
2311        }
2312        "contains_all" => {
2313            if let Some(values) = &assertion.values {
2314                for val in values {
2315                    let expected = value_to_rust_string(val);
2316                    let line = format!(
2317                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2318                    );
2319                    let _ = writeln!(out, "{line}");
2320                }
2321            }
2322        }
2323        "not_contains" => {
2324            if let Some(val) = &assertion.value {
2325                let expected = value_to_rust_string(val);
2326                let line = format!(
2327                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
2328                );
2329                let _ = writeln!(out, "{line}");
2330            }
2331        }
2332        "not_empty" => {
2333            if let Some(f) = &assertion.field {
2334                let resolved = field_resolver.resolve(f);
2335                let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2336                let is_arr = field_resolver.is_array(resolved);
2337                if is_opt && is_arr {
2338                    // Option<Vec<T>>: must be Some AND inner non-empty.
2339                    let accessor = field_resolver.accessor(f, "rust", result_var);
2340                    let _ = writeln!(
2341                        out,
2342                        "    assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
2343                    );
2344                } else if is_opt {
2345                    // Non-collection optional field (e.g., Option<Struct>): use is_some().
2346                    let accessor = field_resolver.accessor(f, "rust", result_var);
2347                    let _ = writeln!(
2348                        out,
2349                        "    assert!({accessor}.is_some(), \"expected {f} to be present\");"
2350                    );
2351                } else {
2352                    let _ = writeln!(
2353                        out,
2354                        "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2355                    );
2356                }
2357            } else if result_is_option {
2358                // Bare result is Option<T>: not_empty == is_some().
2359                let _ = writeln!(
2360                    out,
2361                    "    assert!({field_access}.is_some(), \"expected non-empty value\");"
2362                );
2363            } else {
2364                // Bare result is a struct/string/collection — non-empty via is_empty().
2365                let _ = writeln!(
2366                    out,
2367                    "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2368                );
2369            }
2370        }
2371        "is_empty" => {
2372            if let Some(f) = &assertion.field {
2373                let resolved = field_resolver.resolve(f);
2374                let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2375                let is_arr = field_resolver.is_array(resolved);
2376                if is_opt && is_arr {
2377                    // Option<Vec<T>>: empty means None or empty vec.
2378                    let accessor = field_resolver.accessor(f, "rust", result_var);
2379                    let _ = writeln!(
2380                        out,
2381                        "    assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
2382                    );
2383                } else if is_opt {
2384                    let accessor = field_resolver.accessor(f, "rust", result_var);
2385                    let _ = writeln!(out, "    assert!({accessor}.is_none(), \"expected {f} to be absent\");");
2386                } else {
2387                    let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
2388                }
2389            } else {
2390                let _ = writeln!(out, "    assert!({field_access}.is_none(), \"expected empty value\");");
2391            }
2392        }
2393        "contains_any" => {
2394            if let Some(values) = &assertion.values {
2395                let checks: Vec<String> = values
2396                    .iter()
2397                    .map(|v| {
2398                        let expected = value_to_rust_string(v);
2399                        format!("{field_access}.contains({expected})")
2400                    })
2401                    .collect();
2402                let joined = checks.join(" || ");
2403                let _ = writeln!(
2404                    out,
2405                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
2406                );
2407            }
2408        }
2409        "greater_than" => {
2410            if let Some(val) = &assertion.value {
2411                // Skip comparisons with negative values against unsigned types (.len() etc.)
2412                if val.as_f64().is_some_and(|n| n < 0.0) {
2413                    let _ = writeln!(
2414                        out,
2415                        "    // skipped: greater_than with negative value is always true for unsigned types"
2416                    );
2417                } else if val.as_u64() == Some(0) {
2418                    // Clippy prefers !is_empty() over len() > 0
2419                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2420                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
2421                } else {
2422                    let lit = numeric_literal(val);
2423                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
2424                }
2425            }
2426        }
2427        "less_than" => {
2428            if let Some(val) = &assertion.value {
2429                let lit = numeric_literal(val);
2430                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
2431            }
2432        }
2433        "greater_than_or_equal" => {
2434            if let Some(val) = &assertion.value {
2435                let lit = numeric_literal(val);
2436                // Check whether this field is optional but not an array — e.g. Option<usize>.
2437                // Directly comparing Option<usize> >= N is a type error; wrap with unwrap_or(0).
2438                let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
2439                    let resolved = field_resolver.resolve(f);
2440                    let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2441                    let is_arr = field_resolver.is_array(resolved);
2442                    is_opt && !is_arr
2443                });
2444                if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
2445                    // Clippy prefers !is_empty() over len() >= 1 for collections.
2446                    // Only apply when the expression is already a `.len()` call so we
2447                    // don't mistakenly call `.is_empty()` on numeric (usize) fields.
2448                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2449                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= 1\");");
2450                } else if is_opt_numeric {
2451                    // Option<usize> / Option<u64>: unwrap to 0 before comparing.
2452                    let _ = writeln!(
2453                        out,
2454                        "    assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
2455                    );
2456                } else {
2457                    let _ = writeln!(out, "    assert!({field_access} >= {lit}, \"expected >= {lit}\");");
2458                }
2459            }
2460        }
2461        "less_than_or_equal" => {
2462            if let Some(val) = &assertion.value {
2463                let lit = numeric_literal(val);
2464                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
2465            }
2466        }
2467        "starts_with" => {
2468            if let Some(val) = &assertion.value {
2469                let expected = value_to_rust_string(val);
2470                let _ = writeln!(
2471                    out,
2472                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
2473                );
2474            }
2475        }
2476        "ends_with" => {
2477            if let Some(val) = &assertion.value {
2478                let expected = value_to_rust_string(val);
2479                let _ = writeln!(
2480                    out,
2481                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
2482                );
2483            }
2484        }
2485        "min_length" => {
2486            if let Some(val) = &assertion.value {
2487                if let Some(n) = val.as_u64() {
2488                    let _ = writeln!(
2489                        out,
2490                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
2491                    );
2492                }
2493            }
2494        }
2495        "max_length" => {
2496            if let Some(val) = &assertion.value {
2497                if let Some(n) = val.as_u64() {
2498                    let _ = writeln!(
2499                        out,
2500                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
2501                    );
2502                }
2503            }
2504        }
2505        "count_min" => {
2506            if let Some(val) = &assertion.value {
2507                if let Some(n) = val.as_u64() {
2508                    let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2509                        let resolved = field_resolver.resolve(f);
2510                        let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2511                        let is_arr = field_resolver.is_array(resolved);
2512                        is_opt && is_arr
2513                    });
2514                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2515                    if opt_arr_field {
2516                        // Option<Vec<T>>: must be Some AND inner len >= n.
2517                        if n <= 1 {
2518                            let _ = writeln!(
2519                                out,
2520                                "    assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2521                            );
2522                        } else {
2523                            let _ = writeln!(
2524                                out,
2525                                "    assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
2526                            );
2527                        }
2528                    } else if n <= 1 {
2529                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= {n}\");");
2530                    } else {
2531                        let _ = writeln!(
2532                            out,
2533                            "    assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
2534                        );
2535                    }
2536                }
2537            }
2538        }
2539        "count_equals" => {
2540            if let Some(val) = &assertion.value {
2541                if let Some(n) = val.as_u64() {
2542                    let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2543                        let resolved = field_resolver.resolve(f);
2544                        let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2545                        let is_arr = field_resolver.is_array(resolved);
2546                        is_opt && is_arr
2547                    });
2548                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2549                    if opt_arr_field {
2550                        let _ = writeln!(
2551                            out,
2552                            "    assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
2553                        );
2554                    } else {
2555                        let _ = writeln!(
2556                            out,
2557                            "    assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
2558                        );
2559                    }
2560                }
2561            }
2562        }
2563        "is_true" => {
2564            let _ = writeln!(out, "    assert!({field_access}, \"expected true\");");
2565        }
2566        "is_false" => {
2567            let _ = writeln!(out, "    assert!(!{field_access}, \"expected false\");");
2568        }
2569        "method_result" => {
2570            if let Some(method_name) = &assertion.method {
2571                // Build the call expression. When the result is a tree-sitter Tree (an opaque
2572                // type), methods like `root_child_count` do not exist on `Tree` directly —
2573                // they are free functions in the crate or are accessed via `root_node()`.
2574                let call_expr = if result_is_tree {
2575                    build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
2576                } else if let Some(args) = &assertion.args {
2577                    let arg_lit = json_to_rust_literal(args, "");
2578                    format!("{field_access}.{method_name}({arg_lit})")
2579                } else {
2580                    format!("{field_access}.{method_name}()")
2581                };
2582
2583                // Determine whether the call expression returns a numeric type so we can
2584                // choose the right comparison strategy for `greater_than_or_equal`.
2585                let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
2586
2587                let check = assertion.check.as_deref().unwrap_or("is_true");
2588                match check {
2589                    "equals" => {
2590                        if let Some(val) = &assertion.value {
2591                            if val.is_boolean() {
2592                                if val.as_bool() == Some(true) {
2593                                    let _ = writeln!(
2594                                        out,
2595                                        "    assert!({call_expr}, \"method_result equals assertion failed\");"
2596                                    );
2597                                } else {
2598                                    let _ = writeln!(
2599                                        out,
2600                                        "    assert!(!{call_expr}, \"method_result equals assertion failed\");"
2601                                    );
2602                                }
2603                            } else {
2604                                let expected = value_to_rust_string(val);
2605                                let _ = writeln!(
2606                                    out,
2607                                    "    assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
2608                                );
2609                            }
2610                        }
2611                    }
2612                    "is_true" => {
2613                        let _ = writeln!(
2614                            out,
2615                            "    assert!({call_expr}, \"method_result is_true assertion failed\");"
2616                        );
2617                    }
2618                    "is_false" => {
2619                        let _ = writeln!(
2620                            out,
2621                            "    assert!(!{call_expr}, \"method_result is_false assertion failed\");"
2622                        );
2623                    }
2624                    "greater_than_or_equal" => {
2625                        if let Some(val) = &assertion.value {
2626                            let lit = numeric_literal(val);
2627                            if returns_numeric {
2628                                // Numeric return (e.g., child_count()) — always use >= comparison.
2629                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2630                            } else if val.as_u64() == Some(1) {
2631                                // Clippy prefers !is_empty() over len() >= 1 for collections.
2632                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
2633                            } else {
2634                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2635                            }
2636                        }
2637                    }
2638                    "count_min" => {
2639                        if let Some(val) = &assertion.value {
2640                            let n = val.as_u64().unwrap_or(0);
2641                            if n <= 1 {
2642                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
2643                            } else {
2644                                let _ = writeln!(
2645                                    out,
2646                                    "    assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
2647                                );
2648                            }
2649                        }
2650                    }
2651                    "is_error" => {
2652                        // For is_error we need the raw Result without .unwrap().
2653                        let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
2654                        let _ = writeln!(
2655                            out,
2656                            "    assert!({raw_call}.is_err(), \"expected method to return error\");"
2657                        );
2658                    }
2659                    "contains" => {
2660                        if let Some(val) = &assertion.value {
2661                            let expected = value_to_rust_string(val);
2662                            let _ = writeln!(
2663                                out,
2664                                "    assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
2665                            );
2666                        }
2667                    }
2668                    "not_empty" => {
2669                        let _ = writeln!(
2670                            out,
2671                            "    assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
2672                        );
2673                    }
2674                    "is_empty" => {
2675                        let _ = writeln!(out, "    assert!({call_expr}.is_empty(), \"expected empty result\");");
2676                    }
2677                    other_check => {
2678                        panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
2679                    }
2680                }
2681            } else {
2682                panic!("Rust e2e generator: method_result assertion missing 'method' field");
2683            }
2684        }
2685        other => {
2686            panic!("Rust e2e generator: unsupported assertion type: {other}");
2687        }
2688    }
2689}
2690
2691/// Translate a fixture pseudo-field name on a `tree_sitter::Tree` into the
2692/// correct Rust accessor expression.
2693///
2694/// When an assertion uses `field: "root_child_count"` on a tree result, the
2695/// field resolver would naively emit `tree.root_child_count` — which is invalid
2696/// because `Tree` is an opaque type with no such field.  This function maps the
2697/// pseudo-field to the correct Rust expression instead.
2698fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
2699    match field {
2700        "root_child_count" => format!("{result_var}.root_node().child_count()"),
2701        "root_node_type" => format!("{result_var}.root_node().kind()"),
2702        "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
2703        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
2704        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
2705        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
2706        // Unknown pseudo-field: fall back to direct field access (will likely fail to compile,
2707        // but gives the developer a useful error pointing to the fixture).
2708        other => format!("{result_var}.{other}"),
2709    }
2710}
2711
2712/// Build a Rust call expression for a logical "method" on a `tree_sitter::Tree`.
2713///
2714/// `Tree` is an opaque type — it does not expose methods like `root_child_count`.
2715/// Instead, these are either free functions in the crate or are accessed via
2716/// `tree.root_node().<method>()`. This function translates the fixture-level
2717/// method name into the correct Rust expression.
2718fn build_tree_call_expr(
2719    field_access: &str,
2720    method_name: &str,
2721    args: Option<&serde_json::Value>,
2722    module: &str,
2723) -> String {
2724    match method_name {
2725        "root_child_count" => format!("{field_access}.root_node().child_count()"),
2726        "root_node_type" => format!("{field_access}.root_node().kind()"),
2727        "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
2728        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
2729        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
2730        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
2731        "contains_node_type" => {
2732            let node_type = args
2733                .and_then(|a| a.get("node_type"))
2734                .and_then(|v| v.as_str())
2735                .unwrap_or("");
2736            format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
2737        }
2738        "find_nodes_by_type" => {
2739            let node_type = args
2740                .and_then(|a| a.get("node_type"))
2741                .and_then(|v| v.as_str())
2742                .unwrap_or("");
2743            format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
2744        }
2745        "run_query" => {
2746            let query_source = args
2747                .and_then(|a| a.get("query_source"))
2748                .and_then(|v| v.as_str())
2749                .unwrap_or("");
2750            let language = args
2751                .and_then(|a| a.get("language"))
2752                .and_then(|v| v.as_str())
2753                .unwrap_or("");
2754            // Use a raw string for the query to avoid escaping issues.
2755            // run_query returns Result — unwrap it for assertion access.
2756            format!(
2757                "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
2758            )
2759        }
2760        // Fallback: try as a plain method call.
2761        _ => {
2762            if let Some(args) = args {
2763                let arg_lit = json_to_rust_literal(args, "");
2764                format!("{field_access}.{method_name}({arg_lit})")
2765            } else {
2766                format!("{field_access}.{method_name}()")
2767            }
2768        }
2769    }
2770}
2771
2772/// Returns `true` when the tree method name produces a numeric result (usize/u64),
2773/// meaning `>= N` comparisons should use direct numeric comparison rather than
2774/// `.is_empty()` (which only works for collections).
2775fn is_tree_numeric_method(method_name: &str) -> bool {
2776    matches!(
2777        method_name,
2778        "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
2779    )
2780}
2781
2782/// Convert a JSON numeric value to a Rust literal suitable for comparisons.
2783///
2784/// Whole numbers (no fractional part) are emitted as bare integer literals so
2785/// they are compatible with `usize`, `u64`, etc. (e.g., `.len()` results).
2786/// Numbers with a fractional component get the `_f64` suffix.
2787fn numeric_literal(value: &serde_json::Value) -> String {
2788    if let Some(n) = value.as_f64() {
2789        if n.fract() == 0.0 {
2790            // Whole number — emit without a type suffix so Rust can infer the
2791            // correct integer type from context (usize, u64, i64, …).
2792            return format!("{}", n as i64);
2793        }
2794        return format!("{n}_f64");
2795    }
2796    // Fallback: use the raw JSON representation.
2797    value.to_string()
2798}
2799
2800fn value_to_rust_string(value: &serde_json::Value) -> String {
2801    match value {
2802        serde_json::Value::String(s) => rust_raw_string(s),
2803        serde_json::Value::Bool(b) => format!("{b}"),
2804        serde_json::Value::Number(n) => n.to_string(),
2805        other => {
2806            let s = other.to_string();
2807            format!("\"{s}\"")
2808        }
2809    }
2810}
2811
2812// ---------------------------------------------------------------------------
2813// Visitor generation
2814// ---------------------------------------------------------------------------
2815
2816/// Resolve the visitor trait name based on module.
2817fn resolve_visitor_trait(module: &str) -> String {
2818    // For html_to_markdown modules, use HtmlVisitor
2819    if module.contains("html_to_markdown") {
2820        "HtmlVisitor".to_string()
2821    } else {
2822        // Default fallback for other modules
2823        "Visitor".to_string()
2824    }
2825}
2826
2827/// Emit a Rust visitor method for a callback action.
2828///
2829/// The parameter type list mirrors the `HtmlVisitor` trait in
2830/// `kreuzberg-dev/html-to-markdown`. Param names are bound to `_` because the
2831/// generated visitor body never references them — the body always returns a
2832/// fixed `VisitResult` variant — so we'd otherwise hit `unused_variables`
2833/// warnings that fail prek's `cargo clippy -D warnings` hook.
2834fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2835    // Each entry: parameters typed exactly as `HtmlVisitor` expects them,
2836    // bound to `_` patterns so the generated body needn't introduce unused
2837    // bindings. Receiver is `&mut self` to match the trait.
2838    let params = match method_name {
2839        "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
2840        "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
2841        "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
2842        "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
2843        "visit_code_inline"
2844        | "visit_strong"
2845        | "visit_emphasis"
2846        | "visit_strikethrough"
2847        | "visit_underline"
2848        | "visit_subscript"
2849        | "visit_superscript"
2850        | "visit_mark"
2851        | "visit_button"
2852        | "visit_summary"
2853        | "visit_figcaption"
2854        | "visit_definition_term"
2855        | "visit_definition_description" => "_: &NodeContext, _: &str",
2856        "visit_text" => "_: &NodeContext, _: &str",
2857        "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
2858        "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
2859        "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
2860        "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
2861        "visit_form" => "_: &NodeContext, _: &str, _: &str",
2862        "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
2863        "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
2864        "visit_details" => "_: &NodeContext, _: bool",
2865        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2866            "_: &NodeContext, _: &str"
2867        }
2868        "visit_list_start" => "_: &NodeContext, _: bool",
2869        "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
2870        _ => "_: &NodeContext",
2871    };
2872
2873    let _ = writeln!(out, "        fn {method_name}(&mut self, {params}) -> VisitResult {{");
2874    match action {
2875        CallbackAction::Skip => {
2876            let _ = writeln!(out, "            VisitResult::Skip");
2877        }
2878        CallbackAction::Continue => {
2879            let _ = writeln!(out, "            VisitResult::Continue");
2880        }
2881        CallbackAction::PreserveHtml => {
2882            let _ = writeln!(out, "            VisitResult::PreserveHtml");
2883        }
2884        CallbackAction::Custom { output } => {
2885            let escaped = escape_rust(output);
2886            let _ = writeln!(out, "            VisitResult::Custom(\"{escaped}\".to_string())");
2887        }
2888        CallbackAction::CustomTemplate { template } => {
2889            let escaped = escape_rust(template);
2890            let _ = writeln!(out, "            VisitResult::Custom(format!(\"{escaped}\"))");
2891        }
2892    }
2893    let _ = writeln!(out, "        }}");
2894}