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