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, Fixture, FixtureGroup};
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        let needs_mock_server = groups
46            .iter()
47            .flat_map(|g| g.fixtures.iter())
48            .any(|f| !is_skipped(f, "rust") && f.needs_mock_server());
49
50        // Tokio is needed when any test is async (mock server or async call config).
51        let any_async_call = std::iter::once(&e2e_config.call)
52            .chain(e2e_config.calls.values())
53            .any(|c| c.r#async);
54        let needs_tokio = needs_mock_server || any_async_call;
55
56        let crate_version = resolve_crate_version(e2e_config);
57        files.push(GeneratedFile {
58            path: output_base.join("Cargo.toml"),
59            content: render_cargo_toml(
60                &crate_name,
61                &dep_name,
62                &crate_path,
63                needs_serde_json,
64                needs_mock_server,
65                needs_tokio,
66                e2e_config.dep_mode,
67                crate_version.as_deref(),
68                &alef_config.crate_config.features,
69            ),
70            generated_header: true,
71        });
72
73        // Generate mock_server.rs when at least one fixture uses mock_response.
74        if needs_mock_server {
75            files.push(GeneratedFile {
76                path: output_base.join("tests").join("mock_server.rs"),
77                content: render_mock_server_module(),
78                generated_header: true,
79            });
80            // Generate standalone mock-server binary for cross-language e2e suites.
81            files.push(GeneratedFile {
82                path: output_base.join("src").join("main.rs"),
83                content: render_mock_server_binary(),
84                generated_header: true,
85            });
86        }
87
88        // Per-category test files.
89        for group in groups {
90            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
91
92            if fixtures.is_empty() {
93                continue;
94            }
95
96            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
97            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
98
99            files.push(GeneratedFile {
100                path: output_base.join("tests").join(filename),
101                content,
102                generated_header: true,
103            });
104        }
105
106        Ok(files)
107    }
108
109    fn language_name(&self) -> &'static str {
110        "rust"
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Config resolution helpers
116// ---------------------------------------------------------------------------
117
118fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
119    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
120    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
121    // import identifier, not the Cargo package name.
122    alef_config.crate_config.name.clone()
123}
124
125fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
126    e2e_config
127        .resolve_package("rust")
128        .and_then(|p| p.path.clone())
129        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
130}
131
132fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
133    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
134}
135
136fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
137    call_config
138        .overrides
139        .get("rust")
140        .and_then(|o| o.function.clone())
141        .unwrap_or_else(|| call_config.function.clone())
142}
143
144fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
145    resolve_module_for_call(&e2e_config.call, dep_name)
146}
147
148fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
149    // For Rust, the module name is the crate identifier (underscores).
150    // Priority: override.crate_name > override.module > dep_name
151    let overrides = call_config.overrides.get("rust");
152    overrides
153        .and_then(|o| o.crate_name.clone())
154        .or_else(|| overrides.and_then(|o| o.module.clone()))
155        .unwrap_or_else(|| dep_name.to_string())
156}
157
158fn is_skipped(fixture: &Fixture, language: &str) -> bool {
159    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
160}
161
162// ---------------------------------------------------------------------------
163// Rendering
164// ---------------------------------------------------------------------------
165
166#[allow(clippy::too_many_arguments)]
167pub fn render_cargo_toml(
168    crate_name: &str,
169    dep_name: &str,
170    crate_path: &str,
171    needs_serde_json: bool,
172    needs_mock_server: bool,
173    needs_tokio: bool,
174    dep_mode: crate::config::DependencyMode,
175    version: Option<&str>,
176    features: &[String],
177) -> String {
178    let e2e_name = format!("{dep_name}-e2e-rust");
179    // Use only the features explicitly configured in alef.toml.
180    // Do NOT auto-add "serde" — the target crate may not have that feature.
181    // serde_json is added as a separate dependency when needed.
182    let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
183    let features_str = if effective_features.is_empty() {
184        String::new()
185    } else {
186        format!(", default-features = false, features = {:?}", effective_features)
187    };
188    let dep_spec = match dep_mode {
189        crate::config::DependencyMode::Registry => {
190            let ver = version.unwrap_or("0.1.0");
191            if crate_name != dep_name {
192                format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
193            } else if effective_features.is_empty() {
194                format!("{dep_name} = \"{ver}\"")
195            } else {
196                format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
197            }
198        }
199        crate::config::DependencyMode::Local => {
200            if crate_name != dep_name {
201                format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
202            } else if effective_features.is_empty() {
203                format!("{dep_name} = {{ path = \"{crate_path}\" }}")
204            } else {
205                format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
206            }
207        }
208    };
209    let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
210    // An empty `[workspace]` table makes the e2e crate its own workspace root, so
211    // it never gets pulled into a parent crate's workspace. This means consumers
212    // don't have to remember to add `e2e/rust` to `workspace.exclude`, and
213    // `cargo fmt`/`cargo build` work the same whether the parent has a
214    // workspace or not.
215    // Mock server requires axum (HTTP router) and tokio-stream (SSE streaming).
216    // The standalone binary additionally needs serde (derive) and walkdir.
217    let mock_lines = if needs_mock_server {
218        format!(
219            "\naxum = \"{axum}\"\ntokio-stream = \"{tokio_stream}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
220            axum = tv::cargo::AXUM,
221            tokio_stream = tv::cargo::TOKIO_STREAM,
222            walkdir = tv::cargo::WALKDIR,
223        )
224    } else {
225        String::new()
226    };
227    let mut machete_ignored: Vec<&str> = Vec::new();
228    if needs_serde_json {
229        machete_ignored.push("\"serde_json\"");
230    }
231    if needs_mock_server {
232        machete_ignored.push("\"axum\"");
233        machete_ignored.push("\"tokio-stream\"");
234        machete_ignored.push("\"serde\"");
235        machete_ignored.push("\"walkdir\"");
236    }
237    let machete_section = if machete_ignored.is_empty() {
238        String::new()
239    } else {
240        format!(
241            "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
242            machete_ignored.join(", ")
243        )
244    };
245    let tokio_line = if needs_tokio {
246        "\ntokio = { version = \"1\", features = [\"full\"] }"
247    } else {
248        ""
249    };
250    let bin_section = if needs_mock_server {
251        "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
252    } else {
253        ""
254    };
255    let header = hash::header(CommentStyle::Hash);
256    format!(
257        r#"{header}
258[workspace]
259
260[package]
261name = "{e2e_name}"
262version = "0.1.0"
263edition = "2021"
264license = "MIT"
265publish = false
266{bin_section}
267[dependencies]
268{dep_spec}{serde_line}{mock_lines}{tokio_line}
269{machete_section}"#
270    )
271}
272
273fn render_test_file(
274    category: &str,
275    fixtures: &[&Fixture],
276    e2e_config: &E2eConfig,
277    dep_name: &str,
278    needs_mock_server: bool,
279) -> String {
280    let mut out = String::new();
281    out.push_str(&hash::header(CommentStyle::DoubleSlash));
282    let _ = writeln!(out, "//! E2e tests for category: {category}");
283    let _ = writeln!(out);
284
285    let module = resolve_module(e2e_config, dep_name);
286    let field_resolver = FieldResolver::new(
287        &e2e_config.fields,
288        &e2e_config.fields_optional,
289        &e2e_config.result_fields,
290        &e2e_config.fields_array,
291    );
292
293    // Collect all unique (module, function) pairs needed across all fixtures in this file.
294    // Fixtures that name a specific call may use a different function (and module) than
295    // the default [e2e.call] config.
296    let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
297    for fixture in fixtures.iter() {
298        let call_config = e2e_config.resolve_call(fixture.call.as_deref());
299        let fn_name = resolve_function_name_for_call(call_config);
300        let mod_name = resolve_module_for_call(call_config, dep_name);
301        imported.insert((mod_name, fn_name));
302    }
303    // Emit use statements, grouping by module when possible.
304    let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
305    for (mod_name, fn_name) in &imported {
306        by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
307    }
308    for (mod_name, fns) in &by_module {
309        if fns.len() == 1 {
310            let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
311        } else {
312            let joined = fns.join(", ");
313            let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
314        }
315    }
316
317    // Import handle constructor functions and the config type they use.
318    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
319    if has_handle_args {
320        let _ = writeln!(out, "use {module}::CrawlConfig;");
321    }
322    for arg in &e2e_config.call.args {
323        if arg.arg_type == "handle" {
324            use heck::ToSnakeCase;
325            let constructor_name = format!("create_{}", arg.name.to_snake_case());
326            let _ = writeln!(out, "use {module}::{constructor_name};");
327        }
328    }
329
330    // Import mock_server module when any fixture in this file uses mock_response.
331    let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.needs_mock_server());
332    if file_needs_mock {
333        let _ = writeln!(out, "mod mock_server;");
334        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
335    }
336
337    // Import the visitor trait, result enum, and node context when any fixture
338    // in this file declares a `visitor` block. Without these, the inline
339    // `impl HtmlVisitor for _TestVisitor` block fails to resolve.
340    let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
341    if file_needs_visitor {
342        let visitor_trait = resolve_visitor_trait(&module);
343        let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
344    }
345
346    let _ = writeln!(out);
347
348    for fixture in fixtures {
349        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
350        let _ = writeln!(out);
351    }
352
353    if !out.ends_with('\n') {
354        out.push('\n');
355    }
356    out
357}
358
359fn render_test_function(
360    out: &mut String,
361    fixture: &Fixture,
362    e2e_config: &E2eConfig,
363    dep_name: &str,
364    field_resolver: &FieldResolver,
365) {
366    let fn_name = sanitize_ident(&fixture.id);
367    let description = &fixture.description;
368    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
369    let function_name = resolve_function_name_for_call(call_config);
370    let module = resolve_module_for_call(call_config, dep_name);
371    let result_var = &call_config.result_var;
372    let has_mock = fixture.needs_mock_server();
373
374    // Tests with a mock server are always async (Axum requires a Tokio runtime).
375    let is_async = call_config.r#async || has_mock;
376    if is_async {
377        let _ = writeln!(out, "#[tokio::test]");
378        let _ = writeln!(out, "async fn test_{fn_name}() {{");
379    } else {
380        let _ = writeln!(out, "#[test]");
381        let _ = writeln!(out, "fn test_{fn_name}() {{");
382    }
383    let _ = writeln!(out, "    // {description}");
384
385    // Emit mock server setup before building arguments so arg expressions can
386    // reference `mock_server.url` when needed.
387    if has_mock {
388        render_mock_server_setup(out, fixture, e2e_config);
389    }
390
391    // Check if any assertion is an error assertion.
392    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
393
394    // Emit input variable bindings from args config.
395    let mut arg_exprs: Vec<String> = Vec::new();
396    for arg in &call_config.args {
397        let value = resolve_field(&fixture.input, &arg.field);
398        let var_name = &arg.name;
399        let (bindings, expr) = render_rust_arg(
400            var_name,
401            value,
402            &arg.arg_type,
403            arg.optional,
404            &module,
405            &fixture.id,
406            if has_mock {
407                Some("mock_server.url.as_str()")
408            } else {
409                None
410            },
411        );
412        for binding in &bindings {
413            let _ = writeln!(out, "    {binding}");
414        }
415        arg_exprs.push(expr);
416    }
417
418    // Emit visitor if present in fixture.
419    if let Some(visitor_spec) = &fixture.visitor {
420        let _ = writeln!(out, "    struct _TestVisitor;");
421        let _ = writeln!(out, "    impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
422        for (method_name, action) in &visitor_spec.callbacks {
423            emit_rust_visitor_method(out, method_name, action);
424        }
425        let _ = writeln!(out, "    }}");
426        let _ = writeln!(
427            out,
428            "    let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
429        );
430        arg_exprs.push("Some(visitor)".to_string());
431    }
432
433    let args_str = arg_exprs.join(", ");
434
435    let await_suffix = if is_async { ".await" } else { "" };
436
437    let result_is_tree = call_config.result_var == "tree";
438
439    if has_error_assertion {
440        let _ = writeln!(out, "    let {result_var} = {function_name}({args_str}){await_suffix};");
441        // Render error assertions.
442        for assertion in &fixture.assertions {
443            render_assertion(
444                out,
445                assertion,
446                result_var,
447                &module,
448                dep_name,
449                true,
450                &[],
451                field_resolver,
452                result_is_tree,
453            );
454        }
455        let _ = writeln!(out, "}}");
456        return;
457    }
458
459    // Non-error path: unwrap the result.
460    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
461
462    // Check if any assertion actually uses the result variable.
463    // If all assertions are skipped (field not on result type), use `_` to avoid
464    // Rust's "variable never used" warning.
465    let has_usable_assertion = fixture.assertions.iter().any(|a| {
466        if a.assertion_type == "not_error" || a.assertion_type == "error" {
467            return false;
468        }
469        if a.assertion_type == "method_result" {
470            // method_result assertions that would generate only a TODO comment don't use the
471            // result variable. These are: missing `method` field, or unsupported `check` type.
472            let supported_checks = [
473                "equals",
474                "is_true",
475                "is_false",
476                "greater_than_or_equal",
477                "count_min",
478                "is_error",
479                "contains",
480                "not_empty",
481                "is_empty",
482            ];
483            let check = a.check.as_deref().unwrap_or("is_true");
484            if a.method.is_none() || !supported_checks.contains(&check) {
485                return false;
486            }
487        }
488        match &a.field {
489            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
490            _ => true,
491        }
492    });
493
494    let result_binding = if has_usable_assertion {
495        result_var.to_string()
496    } else {
497        "_".to_string()
498    };
499
500    // Detect Option-returning functions: only skip unwrap when ALL assertions are
501    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
502    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
503    let has_field_access = fixture
504        .assertions
505        .iter()
506        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
507    let only_emptiness_checks = !has_field_access
508        && fixture.assertions.iter().all(|a| {
509            matches!(
510                a.assertion_type.as_str(),
511                "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
512            )
513        });
514
515    let unwrap_suffix = if call_config.returns_result {
516        ".expect(\"should succeed\")"
517    } else {
518        ""
519    };
520    if only_emptiness_checks || !call_config.returns_result {
521        // Option-returning or non-Result-returning: bind raw value, no unwrap.
522        let _ = writeln!(
523            out,
524            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
525        );
526    } else if has_not_error || !fixture.assertions.is_empty() {
527        let _ = writeln!(
528            out,
529            "    let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
530        );
531    } else {
532        let _ = writeln!(
533            out,
534            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
535        );
536    }
537
538    // Emit Option field unwrap bindings for any fields accessed in assertions.
539    // Use FieldResolver to handle optional fields, including nested/aliased paths.
540    let string_assertion_types = [
541        "equals",
542        "contains",
543        "contains_all",
544        "contains_any",
545        "not_contains",
546        "starts_with",
547        "ends_with",
548        "min_length",
549        "max_length",
550        "matches_regex",
551    ];
552    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
553    for assertion in &fixture.assertions {
554        if let Some(f) = &assertion.field {
555            if !f.is_empty()
556                && string_assertion_types.contains(&assertion.assertion_type.as_str())
557                && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
558            {
559                // Only unwrap optional string fields — numeric optionals (u64, usize)
560                // don't support .as_deref() and should be compared directly.
561                let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
562                if !is_string_assertion {
563                    continue;
564                }
565                if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
566                    let _ = writeln!(out, "    {binding}");
567                    unwrapped_fields.push((f.clone(), local_var));
568                }
569            }
570        }
571    }
572
573    // Render assertions.
574    for assertion in &fixture.assertions {
575        if assertion.assertion_type == "not_error" {
576            // Already handled by .expect() above.
577            continue;
578        }
579        render_assertion(
580            out,
581            assertion,
582            result_var,
583            &module,
584            dep_name,
585            false,
586            &unwrapped_fields,
587            field_resolver,
588            result_is_tree,
589        );
590    }
591
592    let _ = writeln!(out, "}}");
593}
594
595// ---------------------------------------------------------------------------
596// Argument rendering
597// ---------------------------------------------------------------------------
598
599fn render_rust_arg(
600    name: &str,
601    value: &serde_json::Value,
602    arg_type: &str,
603    optional: bool,
604    module: &str,
605    fixture_id: &str,
606    mock_base_url: Option<&str>,
607) -> (Vec<String>, String) {
608    if arg_type == "mock_url" {
609        let lines = vec![format!(
610            "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
611        )];
612        return (lines, format!("&{name}"));
613    }
614    // When the arg is a base_url and a mock server is running, use the mock server URL.
615    if arg_type == "base_url" {
616        if let Some(url_expr) = mock_base_url {
617            return (vec![], url_expr.to_string());
618        }
619        // No mock server: fall through to string handling below.
620    }
621    if arg_type == "handle" {
622        // Generate a create_engine (or equivalent) call and pass the config.
623        // If the fixture has input.config, serialize it as a json_object and pass it;
624        // otherwise pass None.
625        use heck::ToSnakeCase;
626        let constructor_name = format!("create_{}", name.to_snake_case());
627        let mut lines = Vec::new();
628        if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
629            lines.push(format!(
630                "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
631            ));
632        } else {
633            // Serialize the config JSON and deserialize at runtime.
634            let json_literal = serde_json::to_string(value).unwrap_or_default();
635            let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
636            lines.push(format!(
637                "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
638            ));
639            lines.push(format!(
640                "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
641            ));
642        }
643        return (lines, format!("&{name}"));
644    }
645    if arg_type == "json_object" {
646        return render_json_object_arg(name, value, optional, module);
647    }
648    if value.is_null() && !optional {
649        // Required arg with no fixture value: use a language-appropriate default.
650        let default_val = match arg_type {
651            "string" => "String::new()".to_string(),
652            "int" | "integer" => "0".to_string(),
653            "float" | "number" => "0.0_f64".to_string(),
654            "bool" | "boolean" => "false".to_string(),
655            _ => "Default::default()".to_string(),
656        };
657        // String args are passed by reference in Rust.
658        let expr = if arg_type == "string" {
659            format!("&{name}")
660        } else {
661            name.to_string()
662        };
663        return (vec![format!("let {name} = {default_val};")], expr);
664    }
665    let literal = json_to_rust_literal(value, arg_type);
666    // String args are passed by reference in Rust.
667    // Bytes args are strings passed as .as_bytes().
668    let pass_by_ref = arg_type == "string" || arg_type == "bytes";
669    let optional_expr = |n: &str| {
670        if arg_type == "string" {
671            format!("{n}.as_deref()")
672        } else if arg_type == "bytes" {
673            format!("{n}.as_deref().map(|v| v.as_slice())")
674        } else {
675            format!("{n}.as_ref()")
676        }
677    };
678    let expr = |n: &str| {
679        if arg_type == "bytes" {
680            format!("{n}.as_bytes()")
681        } else if pass_by_ref {
682            format!("&{n}")
683        } else {
684            n.to_string()
685        }
686    };
687    if optional && value.is_null() {
688        let none_decl = match arg_type {
689            "string" => format!("let {name}: Option<String> = None;"),
690            "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
691            _ => format!("let {name} = None;"),
692        };
693        (vec![none_decl], optional_expr(name))
694    } else if optional {
695        (vec![format!("let {name} = Some({literal});")], optional_expr(name))
696    } else {
697        (vec![format!("let {name} = {literal};")], expr(name))
698    }
699}
700
701/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
702/// and deserialize it through serde at runtime. Type inference from the function signature
703/// determines the concrete type, keeping the generator generic.
704fn render_json_object_arg(
705    name: &str,
706    value: &serde_json::Value,
707    optional: bool,
708    _module: &str,
709) -> (Vec<String>, String) {
710    if value.is_null() && optional {
711        // Use Default::default() and pass by reference — Rust functions typically
712        // take &T not Option<T> for config params.
713        return (vec![format!("let {name} = Default::default();")], format!("&{name}"));
714    }
715
716    // Fixture keys are camelCase; the Rust ConversionOptions type uses snake_case serde.
717    // Normalize keys before building the json! literal so deserialization succeeds.
718    let normalized = super::normalize_json_keys_to_snake_case(value);
719    // Build the json! macro invocation from the fixture object.
720    let json_literal = json_value_to_macro_literal(&normalized);
721    let mut lines = Vec::new();
722    lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
723    // Deserialize to a concrete type inferred from the function signature.
724    let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
725    if optional {
726        lines.push(format!("let {name} = Some({deser_expr});"));
727        (lines, format!("&{name}"))
728    } else {
729        lines.push(format!("let {name} = {deser_expr};"));
730        (lines, format!("&{name}"))
731    }
732}
733
734/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
735fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
736    match value {
737        serde_json::Value::Null => "null".to_string(),
738        serde_json::Value::Bool(b) => format!("{b}"),
739        serde_json::Value::Number(n) => n.to_string(),
740        serde_json::Value::String(s) => {
741            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
742            format!("\"{escaped}\"")
743        }
744        serde_json::Value::Array(arr) => {
745            let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
746            format!("[{}]", items.join(", "))
747        }
748        serde_json::Value::Object(obj) => {
749            let entries: Vec<String> = obj
750                .iter()
751                .map(|(k, v)| {
752                    let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
753                    format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
754                })
755                .collect();
756            format!("{{{}}}", entries.join(", "))
757        }
758    }
759}
760
761fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
762    match value {
763        serde_json::Value::Null => "None".to_string(),
764        serde_json::Value::Bool(b) => format!("{b}"),
765        serde_json::Value::Number(n) => {
766            if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
767                if let Some(f) = n.as_f64() {
768                    return format!("{f}_f64");
769                }
770            }
771            n.to_string()
772        }
773        serde_json::Value::String(s) => rust_raw_string(s),
774        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
775            let json_str = serde_json::to_string(value).unwrap_or_default();
776            let literal = rust_raw_string(&json_str);
777            format!("serde_json::from_str({literal}).unwrap()")
778        }
779    }
780}
781
782// ---------------------------------------------------------------------------
783// Mock server helpers
784// ---------------------------------------------------------------------------
785
786/// Emit mock server setup lines into a test function body.
787///
788/// Builds `MockRoute` objects from the fixture's `mock_response` and starts
789/// the server.  The resulting `mock_server` variable is in scope for the rest
790/// of the test function.
791fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
792    let mock = match fixture.mock_response.as_ref() {
793        Some(m) => m,
794        None => return,
795    };
796
797    // Resolve the HTTP path and method from the call config.
798    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
799    let path = call_config.path.as_deref().unwrap_or("/");
800    let method = call_config.method.as_deref().unwrap_or("POST");
801
802    let status = mock.status;
803
804    if let Some(chunks) = &mock.stream_chunks {
805        // Streaming SSE response.
806        let _ = writeln!(out, "    let mock_route = MockRoute {{");
807        let _ = writeln!(out, "        path: \"{path}\",");
808        let _ = writeln!(out, "        method: \"{method}\",");
809        let _ = writeln!(out, "        status: {status},");
810        let _ = writeln!(out, "        body: String::new(),");
811        let _ = writeln!(out, "        stream_chunks: vec![");
812        for chunk in chunks {
813            let chunk_str = match chunk {
814                serde_json::Value::String(s) => rust_raw_string(s),
815                other => {
816                    let s = serde_json::to_string(other).unwrap_or_default();
817                    rust_raw_string(&s)
818                }
819            };
820            let _ = writeln!(out, "            {chunk_str}.to_string(),");
821        }
822        let _ = writeln!(out, "        ],");
823        let _ = writeln!(out, "    }};");
824    } else {
825        // Non-streaming JSON response.
826        let body_str = match &mock.body {
827            Some(b) => {
828                let s = serde_json::to_string(b).unwrap_or_default();
829                rust_raw_string(&s)
830            }
831            None => rust_raw_string("{}"),
832        };
833        let _ = writeln!(out, "    let mock_route = MockRoute {{");
834        let _ = writeln!(out, "        path: \"{path}\",");
835        let _ = writeln!(out, "        method: \"{method}\",");
836        let _ = writeln!(out, "        status: {status},");
837        let _ = writeln!(out, "        body: {body_str}.to_string(),");
838        let _ = writeln!(out, "        stream_chunks: vec![],");
839        let _ = writeln!(out, "    }};");
840    }
841
842    let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
843}
844
845/// Generate the complete `mock_server.rs` module source.
846fn render_mock_server_module() -> String {
847    // This is parameterized Axum mock server code identical in structure to
848    // liter-llm's mock_server.rs but without any project-specific imports.
849    hash::header(CommentStyle::DoubleSlash)
850        + r#"//
851// Minimal axum-based mock HTTP server for e2e tests.
852
853use std::net::SocketAddr;
854use std::sync::Arc;
855
856use axum::Router;
857use axum::body::Body;
858use axum::extract::State;
859use axum::http::{Request, StatusCode};
860use axum::response::{IntoResponse, Response};
861use tokio::net::TcpListener;
862
863/// A single mock route: match by path + method, return a configured response.
864#[derive(Clone, Debug)]
865pub struct MockRoute {
866    /// URL path to match, e.g. `"/v1/chat/completions"`.
867    pub path: &'static str,
868    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
869    pub method: &'static str,
870    /// HTTP status code to return.
871    pub status: u16,
872    /// Response body JSON string (used when `stream_chunks` is empty).
873    pub body: String,
874    /// Ordered SSE data payloads for streaming responses.
875    /// Each entry becomes `data: <chunk>\n\n` in the response.
876    /// A final `data: [DONE]\n\n` is always appended.
877    pub stream_chunks: Vec<String>,
878}
879
880struct ServerState {
881    routes: Vec<MockRoute>,
882}
883
884pub struct MockServer {
885    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
886    pub url: String,
887    handle: tokio::task::JoinHandle<()>,
888}
889
890impl MockServer {
891    /// Start a mock server with the given routes.  Binds to a random port on
892    /// localhost and returns immediately once the server is listening.
893    pub async fn start(routes: Vec<MockRoute>) -> Self {
894        let state = Arc::new(ServerState { routes });
895
896        let app = Router::new().fallback(handle_request).with_state(state);
897
898        let listener = TcpListener::bind("127.0.0.1:0")
899            .await
900            .expect("Failed to bind mock server port");
901        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
902        let url = format!("http://{addr}");
903
904        let handle = tokio::spawn(async move {
905            axum::serve(listener, app).await.expect("Mock server failed");
906        });
907
908        MockServer { url, handle }
909    }
910
911    /// Stop the mock server.
912    pub fn shutdown(self) {
913        self.handle.abort();
914    }
915}
916
917impl Drop for MockServer {
918    fn drop(&mut self) {
919        self.handle.abort();
920    }
921}
922
923async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
924    let path = req.uri().path().to_owned();
925    let method = req.method().as_str().to_uppercase();
926
927    for route in &state.routes {
928        if route.path == path && route.method.to_uppercase() == method {
929            let status =
930                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
931
932            if !route.stream_chunks.is_empty() {
933                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
934                let mut sse = String::new();
935                for chunk in &route.stream_chunks {
936                    sse.push_str("data: ");
937                    sse.push_str(chunk);
938                    sse.push_str("\n\n");
939                }
940                sse.push_str("data: [DONE]\n\n");
941
942                return Response::builder()
943                    .status(status)
944                    .header("content-type", "text/event-stream")
945                    .header("cache-control", "no-cache")
946                    .body(Body::from(sse))
947                    .unwrap()
948                    .into_response();
949            }
950
951            return Response::builder()
952                .status(status)
953                .header("content-type", "application/json")
954                .body(Body::from(route.body.clone()))
955                .unwrap()
956                .into_response();
957        }
958    }
959
960    // No matching route → 404.
961    Response::builder()
962        .status(StatusCode::NOT_FOUND)
963        .body(Body::from(format!("No mock route for {method} {path}")))
964        .unwrap()
965        .into_response()
966}
967"#
968}
969
970/// Generate the `src/main.rs` for the standalone mock server binary.
971///
972/// The binary:
973/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
974/// - For each fixture that has a `mock_response` field, registers a route at
975///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
976/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
977///   to stdout, then waits until stdin is closed for clean teardown.
978///
979/// This binary is intended for cross-language e2e suites (WASM, Node) that
980/// spawn it as a child process and read the URL from its stdout.
981fn render_mock_server_binary() -> String {
982    hash::header(CommentStyle::DoubleSlash)
983        + r#"//
984// Standalone mock HTTP server binary for cross-language e2e tests.
985// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
986//
987// Usage: mock-server [fixtures-dir]
988//   fixtures-dir defaults to "../../fixtures"
989//
990// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
991// then blocks until stdin is closed (parent process exit triggers cleanup).
992
993use std::collections::HashMap;
994use std::io::{self, BufRead};
995use std::net::SocketAddr;
996use std::path::Path;
997use std::sync::Arc;
998
999use axum::Router;
1000use axum::body::Body;
1001use axum::extract::State;
1002use axum::http::{Request, StatusCode};
1003use axum::response::{IntoResponse, Response};
1004use serde::Deserialize;
1005use tokio::net::TcpListener;
1006
1007// ---------------------------------------------------------------------------
1008// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1009// ---------------------------------------------------------------------------
1010
1011#[derive(Debug, Deserialize)]
1012struct MockResponse {
1013    status: u16,
1014    #[serde(default)]
1015    body: Option<serde_json::Value>,
1016    #[serde(default)]
1017    stream_chunks: Option<Vec<serde_json::Value>>,
1018}
1019
1020#[derive(Debug, Deserialize)]
1021struct Fixture {
1022    id: String,
1023    #[serde(default)]
1024    mock_response: Option<MockResponse>,
1025}
1026
1027// ---------------------------------------------------------------------------
1028// Route table
1029// ---------------------------------------------------------------------------
1030
1031#[derive(Clone, Debug)]
1032struct MockRoute {
1033    status: u16,
1034    body: String,
1035    stream_chunks: Vec<String>,
1036}
1037
1038type RouteTable = Arc<HashMap<String, MockRoute>>;
1039
1040// ---------------------------------------------------------------------------
1041// Axum handler
1042// ---------------------------------------------------------------------------
1043
1044async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1045    let path = req.uri().path().to_owned();
1046
1047    // Try exact match first
1048    if let Some(route) = routes.get(&path) {
1049        return serve_route(route);
1050    }
1051
1052    // Try prefix match: find a route that is a prefix of the request path
1053    // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1054    for (route_path, route) in routes.iter() {
1055        if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1056            return serve_route(route);
1057        }
1058    }
1059
1060    Response::builder()
1061        .status(StatusCode::NOT_FOUND)
1062        .body(Body::from(format!("No mock route for {path}")))
1063        .unwrap()
1064        .into_response()
1065}
1066
1067fn serve_route(route: &MockRoute) -> Response {
1068    let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1069
1070    if !route.stream_chunks.is_empty() {
1071        let mut sse = String::new();
1072        for chunk in &route.stream_chunks {
1073            sse.push_str("data: ");
1074            sse.push_str(chunk);
1075            sse.push_str("\n\n");
1076        }
1077        sse.push_str("data: [DONE]\n\n");
1078
1079        return Response::builder()
1080            .status(status)
1081            .header("content-type", "text/event-stream")
1082            .header("cache-control", "no-cache")
1083            .body(Body::from(sse))
1084            .unwrap()
1085            .into_response();
1086    }
1087
1088    Response::builder()
1089        .status(status)
1090        .header("content-type", "application/json")
1091        .body(Body::from(route.body.clone()))
1092        .unwrap()
1093        .into_response()
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Fixture loading
1098// ---------------------------------------------------------------------------
1099
1100fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1101    let mut routes = HashMap::new();
1102    load_routes_recursive(fixtures_dir, &mut routes);
1103    routes
1104}
1105
1106fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1107    let entries = match std::fs::read_dir(dir) {
1108        Ok(e) => e,
1109        Err(err) => {
1110            eprintln!("warning: cannot read directory {}: {err}", dir.display());
1111            return;
1112        }
1113    };
1114
1115    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1116    paths.sort();
1117
1118    for path in paths {
1119        if path.is_dir() {
1120            load_routes_recursive(&path, routes);
1121        } else if path.extension().is_some_and(|ext| ext == "json") {
1122            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1123            if filename == "schema.json" || filename.starts_with('_') {
1124                continue;
1125            }
1126            let content = match std::fs::read_to_string(&path) {
1127                Ok(c) => c,
1128                Err(err) => {
1129                    eprintln!("warning: cannot read {}: {err}", path.display());
1130                    continue;
1131                }
1132            };
1133            let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1134                match serde_json::from_str(&content) {
1135                    Ok(v) => v,
1136                    Err(err) => {
1137                        eprintln!("warning: cannot parse {}: {err}", path.display());
1138                        continue;
1139                    }
1140                }
1141            } else {
1142                match serde_json::from_str::<Fixture>(&content) {
1143                    Ok(f) => vec![f],
1144                    Err(err) => {
1145                        eprintln!("warning: cannot parse {}: {err}", path.display());
1146                        continue;
1147                    }
1148                }
1149            };
1150
1151            for fixture in fixtures {
1152                if let Some(mock) = fixture.mock_response {
1153                    let route_path = format!("/fixtures/{}", fixture.id);
1154                    let body = mock
1155                        .body
1156                        .as_ref()
1157                        .map(|b| serde_json::to_string(b).unwrap_or_default())
1158                        .unwrap_or_default();
1159                    let stream_chunks = mock
1160                        .stream_chunks
1161                        .unwrap_or_default()
1162                        .into_iter()
1163                        .map(|c| match c {
1164                            serde_json::Value::String(s) => s,
1165                            other => serde_json::to_string(&other).unwrap_or_default(),
1166                        })
1167                        .collect();
1168                    routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks });
1169                }
1170            }
1171        }
1172    }
1173}
1174
1175// ---------------------------------------------------------------------------
1176// Entry point
1177// ---------------------------------------------------------------------------
1178
1179#[tokio::main]
1180async fn main() {
1181    let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1182    let fixtures_dir = Path::new(&fixtures_dir_arg);
1183
1184    let routes = load_routes(fixtures_dir);
1185    eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1186
1187    let route_table: RouteTable = Arc::new(routes);
1188    let app = Router::new().fallback(handle_request).with_state(route_table);
1189
1190    let listener = TcpListener::bind("127.0.0.1:0")
1191        .await
1192        .expect("mock-server: failed to bind port");
1193    let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1194
1195    // Print the URL so the parent process can read it.
1196    println!("MOCK_SERVER_URL=http://{addr}");
1197    // Flush stdout explicitly so the parent does not block waiting.
1198    use std::io::Write;
1199    std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1200
1201    // Spawn the server in the background.
1202    tokio::spawn(async move {
1203        axum::serve(listener, app).await.expect("mock-server: server error");
1204    });
1205
1206    // Block until stdin is closed — the parent process controls lifetime.
1207    let stdin = io::stdin();
1208    let mut lines = stdin.lock().lines();
1209    while lines.next().is_some() {}
1210}
1211"#
1212}
1213
1214// ---------------------------------------------------------------------------
1215// Assertion rendering
1216// ---------------------------------------------------------------------------
1217
1218#[allow(clippy::too_many_arguments)]
1219fn render_assertion(
1220    out: &mut String,
1221    assertion: &Assertion,
1222    result_var: &str,
1223    module: &str,
1224    _dep_name: &str,
1225    is_error_context: bool,
1226    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
1227    field_resolver: &FieldResolver,
1228    result_is_tree: bool,
1229) {
1230    // Skip assertions on fields that don't exist on the result type.
1231    if let Some(f) = &assertion.field {
1232        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1233            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1234            return;
1235        }
1236    }
1237
1238    // Determine field access expression:
1239    // 1. If the field was unwrapped to a local var, use that local var name.
1240    // 2. When the result is a Tree, map pseudo-field names to correct Rust expressions.
1241    // 3. Otherwise, use the field resolver to generate the accessor.
1242    let field_access = match &assertion.field {
1243        Some(f) if !f.is_empty() => {
1244            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
1245                local_var.clone()
1246            } else if result_is_tree {
1247                // Tree is an opaque type — its "fields" are accessed via root_node() or
1248                // free functions. Map known pseudo-field names to correct Rust expressions.
1249                tree_field_access_expr(f, result_var, module)
1250            } else {
1251                field_resolver.accessor(f, "rust", result_var)
1252            }
1253        }
1254        _ => result_var.to_string(),
1255    };
1256
1257    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
1258    let is_unwrapped = assertion
1259        .field
1260        .as_ref()
1261        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
1262
1263    match assertion.assertion_type.as_str() {
1264        "error" => {
1265            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
1266            if let Some(serde_json::Value::String(msg)) = &assertion.value {
1267                let escaped = escape_rust(msg);
1268                let _ = writeln!(
1269                    out,
1270                    "    assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
1271                );
1272            }
1273        }
1274        "not_error" => {
1275            // Handled at call site; nothing extra needed here.
1276        }
1277        "equals" => {
1278            if let Some(val) = &assertion.value {
1279                let expected = value_to_rust_string(val);
1280                if is_error_context {
1281                    return;
1282                }
1283                // For string equality, trim trailing whitespace to handle trailing newlines
1284                // from the converter.
1285                if val.is_string() {
1286                    let _ = writeln!(
1287                        out,
1288                        "    assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
1289                    );
1290                } else if val.is_boolean() {
1291                    // Use assert!/assert!(!...) for booleans — clippy prefers this over assert_eq!(_, true/false).
1292                    if val.as_bool() == Some(true) {
1293                        let _ = writeln!(out, "    assert!({field_access}, \"equals assertion failed\");");
1294                    } else {
1295                        let _ = writeln!(out, "    assert!(!{field_access}, \"equals assertion failed\");");
1296                    }
1297                } else {
1298                    // Wrap expected value in Some() for optional fields.
1299                    let is_opt = assertion.field.as_ref().is_some_and(|f| {
1300                        let resolved = field_resolver.resolve(f);
1301                        field_resolver.is_optional(resolved)
1302                    });
1303                    if is_opt
1304                        && !unwrapped_fields
1305                            .iter()
1306                            .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
1307                    {
1308                        let _ = writeln!(
1309                            out,
1310                            "    assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
1311                        );
1312                    } else {
1313                        let _ = writeln!(
1314                            out,
1315                            "    assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
1316                        );
1317                    }
1318                }
1319            }
1320        }
1321        "contains" => {
1322            if let Some(val) = &assertion.value {
1323                let expected = value_to_rust_string(val);
1324                let line = format!(
1325                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1326                );
1327                let _ = writeln!(out, "{line}");
1328            }
1329        }
1330        "contains_all" => {
1331            if let Some(values) = &assertion.values {
1332                for val in values {
1333                    let expected = value_to_rust_string(val);
1334                    let line = format!(
1335                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1336                    );
1337                    let _ = writeln!(out, "{line}");
1338                }
1339            }
1340        }
1341        "not_contains" => {
1342            if let Some(val) = &assertion.value {
1343                let expected = value_to_rust_string(val);
1344                let line = format!(
1345                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
1346                );
1347                let _ = writeln!(out, "{line}");
1348            }
1349        }
1350        "not_empty" => {
1351            if let Some(f) = &assertion.field {
1352                let resolved = field_resolver.resolve(f);
1353                if !is_unwrapped && field_resolver.is_optional(resolved) {
1354                    // Non-string optional field (e.g., Option<Struct>): use is_some()
1355                    let accessor = field_resolver.accessor(f, "rust", result_var);
1356                    let _ = writeln!(
1357                        out,
1358                        "    assert!({accessor}.is_some(), \"expected {f} to be present\");"
1359                    );
1360                } else {
1361                    let _ = writeln!(
1362                        out,
1363                        "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1364                    );
1365                }
1366            } else {
1367                // No field: assertion on the result itself. Use is_some() for Option types.
1368                let _ = writeln!(
1369                    out,
1370                    "    assert!({field_access}.is_some(), \"expected non-empty value\");"
1371                );
1372            }
1373        }
1374        "is_empty" => {
1375            if let Some(f) = &assertion.field {
1376                let resolved = field_resolver.resolve(f);
1377                if !is_unwrapped && field_resolver.is_optional(resolved) {
1378                    let accessor = field_resolver.accessor(f, "rust", result_var);
1379                    let _ = writeln!(out, "    assert!({accessor}.is_none(), \"expected {f} to be absent\");");
1380                } else {
1381                    let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
1382                }
1383            } else {
1384                // No field: assertion on the result itself. Use is_none() for Option types.
1385                let _ = writeln!(out, "    assert!({field_access}.is_none(), \"expected empty value\");");
1386            }
1387        }
1388        "contains_any" => {
1389            if let Some(values) = &assertion.values {
1390                let checks: Vec<String> = values
1391                    .iter()
1392                    .map(|v| {
1393                        let expected = value_to_rust_string(v);
1394                        format!("{field_access}.contains({expected})")
1395                    })
1396                    .collect();
1397                let joined = checks.join(" || ");
1398                let _ = writeln!(
1399                    out,
1400                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
1401                );
1402            }
1403        }
1404        "greater_than" => {
1405            if let Some(val) = &assertion.value {
1406                // Skip comparisons with negative values against unsigned types (.len() etc.)
1407                if val.as_f64().is_some_and(|n| n < 0.0) {
1408                    let _ = writeln!(
1409                        out,
1410                        "    // skipped: greater_than with negative value is always true for unsigned types"
1411                    );
1412                } else if val.as_u64() == Some(0) {
1413                    // Clippy prefers !is_empty() over len() > 0
1414                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1415                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
1416                } else {
1417                    let lit = numeric_literal(val);
1418                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
1419                }
1420            }
1421        }
1422        "less_than" => {
1423            if let Some(val) = &assertion.value {
1424                let lit = numeric_literal(val);
1425                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
1426            }
1427        }
1428        "greater_than_or_equal" => {
1429            if let Some(val) = &assertion.value {
1430                let lit = numeric_literal(val);
1431                if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
1432                    // Clippy prefers !is_empty() over len() >= 1 for collections.
1433                    // Only apply when the expression is already a `.len()` call so we
1434                    // don't mistakenly call `.is_empty()` on numeric (usize) fields.
1435                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1436                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= 1\");");
1437                } else {
1438                    let _ = writeln!(out, "    assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1439                }
1440            }
1441        }
1442        "less_than_or_equal" => {
1443            if let Some(val) = &assertion.value {
1444                let lit = numeric_literal(val);
1445                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1446            }
1447        }
1448        "starts_with" => {
1449            if let Some(val) = &assertion.value {
1450                let expected = value_to_rust_string(val);
1451                let _ = writeln!(
1452                    out,
1453                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1454                );
1455            }
1456        }
1457        "ends_with" => {
1458            if let Some(val) = &assertion.value {
1459                let expected = value_to_rust_string(val);
1460                let _ = writeln!(
1461                    out,
1462                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1463                );
1464            }
1465        }
1466        "min_length" => {
1467            if let Some(val) = &assertion.value {
1468                if let Some(n) = val.as_u64() {
1469                    let _ = writeln!(
1470                        out,
1471                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
1472                    );
1473                }
1474            }
1475        }
1476        "max_length" => {
1477            if let Some(val) = &assertion.value {
1478                if let Some(n) = val.as_u64() {
1479                    let _ = writeln!(
1480                        out,
1481                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
1482                    );
1483                }
1484            }
1485        }
1486        "count_min" => {
1487            if let Some(val) = &assertion.value {
1488                if let Some(n) = val.as_u64() {
1489                    if n <= 1 {
1490                        // Clippy prefers !is_empty() over len() >= 1
1491                        let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1492                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= {n}\");");
1493                    } else {
1494                        let _ = writeln!(
1495                            out,
1496                            "    assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
1497                        );
1498                    }
1499                }
1500            }
1501        }
1502        "count_equals" => {
1503            if let Some(val) = &assertion.value {
1504                if let Some(n) = val.as_u64() {
1505                    let _ = writeln!(
1506                        out,
1507                        "    assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
1508                    );
1509                }
1510            }
1511        }
1512        "is_true" => {
1513            let _ = writeln!(out, "    assert!({field_access}, \"expected true\");");
1514        }
1515        "is_false" => {
1516            let _ = writeln!(out, "    assert!(!{field_access}, \"expected false\");");
1517        }
1518        "method_result" => {
1519            if let Some(method_name) = &assertion.method {
1520                // Build the call expression. When the result is a tree-sitter Tree (an opaque
1521                // type), methods like `root_child_count` do not exist on `Tree` directly —
1522                // they are free functions in the crate or are accessed via `root_node()`.
1523                let call_expr = if result_is_tree {
1524                    build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
1525                } else if let Some(args) = &assertion.args {
1526                    let arg_lit = json_to_rust_literal(args, "");
1527                    format!("{field_access}.{method_name}({arg_lit})")
1528                } else {
1529                    format!("{field_access}.{method_name}()")
1530                };
1531
1532                // Determine whether the call expression returns a numeric type so we can
1533                // choose the right comparison strategy for `greater_than_or_equal`.
1534                let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
1535
1536                let check = assertion.check.as_deref().unwrap_or("is_true");
1537                match check {
1538                    "equals" => {
1539                        if let Some(val) = &assertion.value {
1540                            if val.is_boolean() {
1541                                if val.as_bool() == Some(true) {
1542                                    let _ = writeln!(
1543                                        out,
1544                                        "    assert!({call_expr}, \"method_result equals assertion failed\");"
1545                                    );
1546                                } else {
1547                                    let _ = writeln!(
1548                                        out,
1549                                        "    assert!(!{call_expr}, \"method_result equals assertion failed\");"
1550                                    );
1551                                }
1552                            } else {
1553                                let expected = value_to_rust_string(val);
1554                                let _ = writeln!(
1555                                    out,
1556                                    "    assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
1557                                );
1558                            }
1559                        }
1560                    }
1561                    "is_true" => {
1562                        let _ = writeln!(
1563                            out,
1564                            "    assert!({call_expr}, \"method_result is_true assertion failed\");"
1565                        );
1566                    }
1567                    "is_false" => {
1568                        let _ = writeln!(
1569                            out,
1570                            "    assert!(!{call_expr}, \"method_result is_false assertion failed\");"
1571                        );
1572                    }
1573                    "greater_than_or_equal" => {
1574                        if let Some(val) = &assertion.value {
1575                            let lit = numeric_literal(val);
1576                            if returns_numeric {
1577                                // Numeric return (e.g., child_count()) — always use >= comparison.
1578                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1579                            } else if val.as_u64() == Some(1) {
1580                                // Clippy prefers !is_empty() over len() >= 1 for collections.
1581                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
1582                            } else {
1583                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1584                            }
1585                        }
1586                    }
1587                    "count_min" => {
1588                        if let Some(val) = &assertion.value {
1589                            let n = val.as_u64().unwrap_or(0);
1590                            if n <= 1 {
1591                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
1592                            } else {
1593                                let _ = writeln!(
1594                                    out,
1595                                    "    assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
1596                                );
1597                            }
1598                        }
1599                    }
1600                    "is_error" => {
1601                        // For is_error we need the raw Result without .unwrap().
1602                        let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
1603                        let _ = writeln!(
1604                            out,
1605                            "    assert!({raw_call}.is_err(), \"expected method to return error\");"
1606                        );
1607                    }
1608                    "contains" => {
1609                        if let Some(val) = &assertion.value {
1610                            let expected = value_to_rust_string(val);
1611                            let _ = writeln!(
1612                                out,
1613                                "    assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
1614                            );
1615                        }
1616                    }
1617                    "not_empty" => {
1618                        let _ = writeln!(
1619                            out,
1620                            "    assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
1621                        );
1622                    }
1623                    "is_empty" => {
1624                        let _ = writeln!(out, "    assert!({call_expr}.is_empty(), \"expected empty result\");");
1625                    }
1626                    other_check => {
1627                        panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
1628                    }
1629                }
1630            } else {
1631                panic!("Rust e2e generator: method_result assertion missing 'method' field");
1632            }
1633        }
1634        other => {
1635            panic!("Rust e2e generator: unsupported assertion type: {other}");
1636        }
1637    }
1638}
1639
1640/// Translate a fixture pseudo-field name on a `tree_sitter::Tree` into the
1641/// correct Rust accessor expression.
1642///
1643/// When an assertion uses `field: "root_child_count"` on a tree result, the
1644/// field resolver would naively emit `tree.root_child_count` — which is invalid
1645/// because `Tree` is an opaque type with no such field.  This function maps the
1646/// pseudo-field to the correct Rust expression instead.
1647fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
1648    match field {
1649        "root_child_count" => format!("{result_var}.root_node().child_count()"),
1650        "root_node_type" => format!("{result_var}.root_node().kind()"),
1651        "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1652        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
1653        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
1654        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
1655        // Unknown pseudo-field: fall back to direct field access (will likely fail to compile,
1656        // but gives the developer a useful error pointing to the fixture).
1657        other => format!("{result_var}.{other}"),
1658    }
1659}
1660
1661/// Build a Rust call expression for a logical "method" on a `tree_sitter::Tree`.
1662///
1663/// `Tree` is an opaque type — it does not expose methods like `root_child_count`.
1664/// Instead, these are either free functions in the crate or are accessed via
1665/// `tree.root_node().<method>()`. This function translates the fixture-level
1666/// method name into the correct Rust expression.
1667fn build_tree_call_expr(
1668    field_access: &str,
1669    method_name: &str,
1670    args: Option<&serde_json::Value>,
1671    module: &str,
1672) -> String {
1673    match method_name {
1674        "root_child_count" => format!("{field_access}.root_node().child_count()"),
1675        "root_node_type" => format!("{field_access}.root_node().kind()"),
1676        "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
1677        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
1678        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
1679        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
1680        "contains_node_type" => {
1681            let node_type = args
1682                .and_then(|a| a.get("node_type"))
1683                .and_then(|v| v.as_str())
1684                .unwrap_or("");
1685            format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
1686        }
1687        "find_nodes_by_type" => {
1688            let node_type = args
1689                .and_then(|a| a.get("node_type"))
1690                .and_then(|v| v.as_str())
1691                .unwrap_or("");
1692            format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
1693        }
1694        "run_query" => {
1695            let query_source = args
1696                .and_then(|a| a.get("query_source"))
1697                .and_then(|v| v.as_str())
1698                .unwrap_or("");
1699            let language = args
1700                .and_then(|a| a.get("language"))
1701                .and_then(|v| v.as_str())
1702                .unwrap_or("");
1703            // Use a raw string for the query to avoid escaping issues.
1704            // run_query returns Result — unwrap it for assertion access.
1705            format!(
1706                "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
1707            )
1708        }
1709        // Fallback: try as a plain method call.
1710        _ => {
1711            if let Some(args) = args {
1712                let arg_lit = json_to_rust_literal(args, "");
1713                format!("{field_access}.{method_name}({arg_lit})")
1714            } else {
1715                format!("{field_access}.{method_name}()")
1716            }
1717        }
1718    }
1719}
1720
1721/// Returns `true` when the tree method name produces a numeric result (usize/u64),
1722/// meaning `>= N` comparisons should use direct numeric comparison rather than
1723/// `.is_empty()` (which only works for collections).
1724fn is_tree_numeric_method(method_name: &str) -> bool {
1725    matches!(
1726        method_name,
1727        "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
1728    )
1729}
1730
1731/// Convert a JSON numeric value to a Rust literal suitable for comparisons.
1732///
1733/// Whole numbers (no fractional part) are emitted as bare integer literals so
1734/// they are compatible with `usize`, `u64`, etc. (e.g., `.len()` results).
1735/// Numbers with a fractional component get the `_f64` suffix.
1736fn numeric_literal(value: &serde_json::Value) -> String {
1737    if let Some(n) = value.as_f64() {
1738        if n.fract() == 0.0 {
1739            // Whole number — emit without a type suffix so Rust can infer the
1740            // correct integer type from context (usize, u64, i64, …).
1741            return format!("{}", n as i64);
1742        }
1743        return format!("{n}_f64");
1744    }
1745    // Fallback: use the raw JSON representation.
1746    value.to_string()
1747}
1748
1749fn value_to_rust_string(value: &serde_json::Value) -> String {
1750    match value {
1751        serde_json::Value::String(s) => rust_raw_string(s),
1752        serde_json::Value::Bool(b) => format!("{b}"),
1753        serde_json::Value::Number(n) => n.to_string(),
1754        other => {
1755            let s = other.to_string();
1756            format!("\"{s}\"")
1757        }
1758    }
1759}
1760
1761// ---------------------------------------------------------------------------
1762// Visitor generation
1763// ---------------------------------------------------------------------------
1764
1765/// Resolve the visitor trait name based on module.
1766fn resolve_visitor_trait(module: &str) -> String {
1767    // For html_to_markdown modules, use HtmlVisitor
1768    if module.contains("html_to_markdown") {
1769        "HtmlVisitor".to_string()
1770    } else {
1771        // Default fallback for other modules
1772        "Visitor".to_string()
1773    }
1774}
1775
1776/// Emit a Rust visitor method for a callback action.
1777///
1778/// The parameter type list mirrors the `HtmlVisitor` trait in
1779/// `kreuzberg-dev/html-to-markdown`. Param names are bound to `_` because the
1780/// generated visitor body never references them — the body always returns a
1781/// fixed `VisitResult` variant — so we'd otherwise hit `unused_variables`
1782/// warnings that fail prek's `cargo clippy -D warnings` hook.
1783fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1784    // Each entry: parameters typed exactly as `HtmlVisitor` expects them,
1785    // bound to `_` patterns so the generated body needn't introduce unused
1786    // bindings. Receiver is `&mut self` to match the trait.
1787    let params = match method_name {
1788        "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
1789        "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
1790        "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
1791        "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
1792        "visit_code_inline"
1793        | "visit_strong"
1794        | "visit_emphasis"
1795        | "visit_strikethrough"
1796        | "visit_underline"
1797        | "visit_subscript"
1798        | "visit_superscript"
1799        | "visit_mark"
1800        | "visit_button"
1801        | "visit_summary"
1802        | "visit_figcaption"
1803        | "visit_definition_term"
1804        | "visit_definition_description" => "_: &NodeContext, _: &str",
1805        "visit_text" => "_: &NodeContext, _: &str",
1806        "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
1807        "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
1808        "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
1809        "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
1810        "visit_form" => "_: &NodeContext, _: &str, _: &str",
1811        "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
1812        "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
1813        "visit_details" => "_: &NodeContext, _: bool",
1814        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1815            "_: &NodeContext, _: &str"
1816        }
1817        "visit_list_start" => "_: &NodeContext, _: bool",
1818        "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
1819        _ => "_: &NodeContext",
1820    };
1821
1822    let _ = writeln!(out, "        fn {method_name}(&mut self, {params}) -> VisitResult {{");
1823    match action {
1824        CallbackAction::Skip => {
1825            let _ = writeln!(out, "            VisitResult::Skip");
1826        }
1827        CallbackAction::Continue => {
1828            let _ = writeln!(out, "            VisitResult::Continue");
1829        }
1830        CallbackAction::PreserveHtml => {
1831            let _ = writeln!(out, "            VisitResult::PreserveHtml");
1832        }
1833        CallbackAction::Custom { output } => {
1834            let escaped = escape_rust(output);
1835            let _ = writeln!(out, "            VisitResult::Custom(\"{escaped}\".to_string())");
1836        }
1837        CallbackAction::CustomTemplate { template } => {
1838            let escaped = escape_rust(template);
1839            let _ = writeln!(out, "            VisitResult::Custom(format!(\"{escaped}\"))");
1840        }
1841    }
1842    let _ = writeln!(out, "        }}");
1843}