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