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