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