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, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16/// Rust e2e test code generator.
17pub struct RustE2eCodegen;
18
19impl super::E2eCodegen for RustE2eCodegen {
20    fn generate(
21        &self,
22        groups: &[FixtureGroup],
23        e2e_config: &E2eConfig,
24        alef_config: &AlefConfig,
25    ) -> Result<Vec<GeneratedFile>> {
26        let mut files = Vec::new();
27        let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
28
29        // Resolve crate name and path from config.
30        let crate_name = resolve_crate_name(e2e_config, alef_config);
31        let crate_path = resolve_crate_path(e2e_config, &crate_name);
32        let dep_name = crate_name.replace('-', "_");
33
34        // Cargo.toml
35        // Check if any fixture uses json_object args (needs serde_json dep).
36        let needs_serde_json = e2e_config
37            .call
38            .args
39            .iter()
40            .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
41        let crate_version = resolve_crate_version(e2e_config);
42        files.push(GeneratedFile {
43            path: output_base.join("Cargo.toml"),
44            content: render_cargo_toml(
45                &crate_name,
46                &dep_name,
47                &crate_path,
48                needs_serde_json,
49                e2e_config.dep_mode,
50                crate_version.as_deref(),
51            ),
52            generated_header: true,
53        });
54
55        // Per-category test files.
56        for group in groups {
57            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
58
59            if fixtures.is_empty() {
60                continue;
61            }
62
63            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
64            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name);
65
66            files.push(GeneratedFile {
67                path: output_base.join("tests").join(filename),
68                content,
69                generated_header: true,
70            });
71        }
72
73        Ok(files)
74    }
75
76    fn language_name(&self) -> &'static str {
77        "rust"
78    }
79}
80
81// ---------------------------------------------------------------------------
82// Config resolution helpers
83// ---------------------------------------------------------------------------
84
85fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
86    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
87    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
88    // import identifier, not the Cargo package name.
89    alef_config.crate_config.name.clone()
90}
91
92fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
93    e2e_config
94        .resolve_package("rust")
95        .and_then(|p| p.path.clone())
96        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
97}
98
99fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
100    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
101}
102
103fn resolve_function_name(e2e_config: &E2eConfig) -> String {
104    e2e_config
105        .call
106        .overrides
107        .get("rust")
108        .and_then(|o| o.function.clone())
109        .unwrap_or_else(|| e2e_config.call.function.clone())
110}
111
112fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
113    // For Rust, the module name is the crate identifier (underscores).
114    // Priority: override.crate_name > override.module > dep_name
115    let overrides = e2e_config.call.overrides.get("rust");
116    overrides
117        .and_then(|o| o.crate_name.clone())
118        .or_else(|| overrides.and_then(|o| o.module.clone()))
119        .unwrap_or_else(|| dep_name.to_string())
120}
121
122fn is_skipped(fixture: &Fixture, language: &str) -> bool {
123    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
124}
125
126// ---------------------------------------------------------------------------
127// Rendering
128// ---------------------------------------------------------------------------
129
130fn render_cargo_toml(
131    crate_name: &str,
132    dep_name: &str,
133    crate_path: &str,
134    needs_serde_json: bool,
135    dep_mode: crate::config::DependencyMode,
136    version: Option<&str>,
137) -> String {
138    let e2e_name = format!("{dep_name}-e2e-rust");
139    let dep_spec = match dep_mode {
140        crate::config::DependencyMode::Registry => {
141            let ver = version.unwrap_or("0.1.0");
142            if crate_name != dep_name {
143                format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\" }}")
144            } else {
145                format!("{dep_name} = \"{ver}\"")
146            }
147        }
148        crate::config::DependencyMode::Local => {
149            // When the crate name has hyphens, Cargo needs `package = "name-with-hyphens"`
150            // because the dep key uses underscores (Rust identifier).
151            if crate_name != dep_name {
152                format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\" }}")
153            } else {
154                format!("{dep_name} = {{ path = \"{crate_path}\" }}")
155            }
156        }
157    };
158    let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
159    // When using registry mode the generated Cargo.toml lives inside a directory
160    // that may be auto-discovered as part of a parent Cargo workspace.  Adding an
161    // empty [workspace] table tells Cargo that this crate is its own standalone
162    // workspace and opts out of any parent workspace discovery.
163    let workspace_section = match dep_mode {
164        crate::config::DependencyMode::Registry => "\n[workspace]\n",
165        crate::config::DependencyMode::Local => "",
166    };
167    format!(
168        r#"# This file is auto-generated by alef. DO NOT EDIT.
169{workspace_section}
170[package]
171name = "{e2e_name}"
172version = "0.1.0"
173edition = "2021"
174license = "MIT"
175publish = false
176
177[dependencies]
178{dep_spec}{serde_line}
179tokio = {{ version = "1", features = ["full"] }}
180wiremock = "0.6"
181"#
182    )
183}
184
185fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, dep_name: &str) -> String {
186    let mut out = String::new();
187    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
188    let _ = writeln!(out, "//! E2e tests for category: {category}");
189    let _ = writeln!(out);
190
191    let module = resolve_module(e2e_config, dep_name);
192    let function_name = resolve_function_name(e2e_config);
193    let field_resolver = FieldResolver::new(
194        &e2e_config.fields,
195        &e2e_config.fields_optional,
196        &e2e_config.result_fields,
197        &e2e_config.fields_array,
198    );
199
200    let _ = writeln!(out, "use {module}::{function_name};");
201
202    // Import handle constructor functions and the config type they use.
203    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
204    if has_handle_args {
205        let _ = writeln!(out, "use {module}::CrawlConfig;");
206    }
207    for arg in &e2e_config.call.args {
208        if arg.arg_type == "handle" {
209            use heck::ToSnakeCase;
210            let constructor_name = format!("create_{}", arg.name.to_snake_case());
211            let _ = writeln!(out, "use {module}::{constructor_name};");
212        }
213    }
214
215    let _ = writeln!(out);
216
217    for fixture in fixtures {
218        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
219        let _ = writeln!(out);
220    }
221
222    if !out.ends_with('\n') {
223        out.push('\n');
224    }
225    out
226}
227
228fn render_test_function(
229    out: &mut String,
230    fixture: &Fixture,
231    e2e_config: &E2eConfig,
232    dep_name: &str,
233    field_resolver: &FieldResolver,
234) {
235    let fn_name = sanitize_ident(&fixture.id);
236    let description = &fixture.description;
237    let function_name = resolve_function_name(e2e_config);
238    let module = resolve_module(e2e_config, dep_name);
239    let result_var = &e2e_config.call.result_var;
240
241    let is_async = e2e_config.call.r#async;
242    if is_async {
243        let _ = writeln!(out, "#[tokio::test]");
244        let _ = writeln!(out, "async fn test_{fn_name}() {{");
245    } else {
246        let _ = writeln!(out, "#[test]");
247        let _ = writeln!(out, "fn test_{fn_name}() {{");
248    }
249    let _ = writeln!(out, "    // {description}");
250
251    // Check if any assertion is an error assertion.
252    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
253
254    // Emit input variable bindings from args config.
255    let mut arg_exprs: Vec<String> = Vec::new();
256    for arg in &e2e_config.call.args {
257        let value = resolve_field(&fixture.input, &arg.field);
258        let var_name = &arg.name;
259        let (bindings, expr) = render_rust_arg(var_name, value, &arg.arg_type, arg.optional, &module, &fixture.id);
260        for binding in &bindings {
261            let _ = writeln!(out, "    {binding}");
262        }
263        arg_exprs.push(expr);
264    }
265
266    let args_str = arg_exprs.join(", ");
267
268    let await_suffix = if is_async { ".await" } else { "" };
269
270    if has_error_assertion {
271        let _ = writeln!(out, "    let {result_var} = {function_name}({args_str}){await_suffix};");
272        // Render error assertions.
273        for assertion in &fixture.assertions {
274            render_assertion(out, assertion, result_var, dep_name, true, &[], field_resolver);
275        }
276        let _ = writeln!(out, "}}");
277        return;
278    }
279
280    // Non-error path: unwrap the result.
281    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
282
283    // Check if any assertion actually uses the result variable.
284    // If all assertions are skipped (field not on result type), use `_` to avoid
285    // Rust's "variable never used" warning.
286    let has_usable_assertion = fixture.assertions.iter().any(|a| {
287        if a.assertion_type == "not_error" || a.assertion_type == "error" {
288            return false;
289        }
290        match &a.field {
291            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
292            _ => true,
293        }
294    });
295
296    let result_binding = if has_usable_assertion {
297        result_var.to_string()
298    } else {
299        "_".to_string()
300    };
301
302    if has_not_error || !fixture.assertions.is_empty() {
303        let _ = writeln!(
304            out,
305            "    let {result_binding} = {function_name}({args_str}){await_suffix}.expect(\"should succeed\");"
306        );
307    } else {
308        let _ = writeln!(
309            out,
310            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
311        );
312    }
313
314    // Emit Option field unwrap bindings for any fields accessed in assertions.
315    // Use FieldResolver to handle optional fields, including nested/aliased paths.
316    let string_assertion_types = [
317        "equals",
318        "contains",
319        "contains_all",
320        "contains_any",
321        "not_contains",
322        "starts_with",
323        "ends_with",
324        "min_length",
325        "max_length",
326        "matches_regex",
327    ];
328    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
329    for assertion in &fixture.assertions {
330        if let Some(f) = &assertion.field {
331            if !f.is_empty()
332                && string_assertion_types.contains(&assertion.assertion_type.as_str())
333                && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
334            {
335                // Only unwrap optional string fields — numeric optionals (u64, usize)
336                // don't support .as_deref() and should be compared directly.
337                let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
338                if !is_string_assertion {
339                    continue;
340                }
341                if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
342                    let _ = writeln!(out, "    {binding}");
343                    unwrapped_fields.push((f.clone(), local_var));
344                }
345            }
346        }
347    }
348
349    // Render assertions.
350    for assertion in &fixture.assertions {
351        if assertion.assertion_type == "not_error" {
352            // Already handled by .expect() above.
353            continue;
354        }
355        render_assertion(
356            out,
357            assertion,
358            result_var,
359            dep_name,
360            false,
361            &unwrapped_fields,
362            field_resolver,
363        );
364    }
365
366    let _ = writeln!(out, "}}");
367}
368
369// ---------------------------------------------------------------------------
370// Argument rendering
371// ---------------------------------------------------------------------------
372
373fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
374    let mut current = input;
375    for part in field_path.split('.') {
376        current = current.get(part).unwrap_or(&serde_json::Value::Null);
377    }
378    current
379}
380
381fn render_rust_arg(
382    name: &str,
383    value: &serde_json::Value,
384    arg_type: &str,
385    optional: bool,
386    module: &str,
387    fixture_id: &str,
388) -> (Vec<String>, String) {
389    if arg_type == "mock_url" {
390        let lines = vec![format!(
391            "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
392        )];
393        return (lines, format!("&{name}"));
394    }
395    if arg_type == "handle" {
396        // Generate a create_engine (or equivalent) call and pass the config.
397        // If the fixture has input.config, serialize it as a json_object and pass it;
398        // otherwise pass None.
399        use heck::ToSnakeCase;
400        let constructor_name = format!("create_{}", name.to_snake_case());
401        let mut lines = Vec::new();
402        if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
403            lines.push(format!(
404                "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
405            ));
406        } else {
407            // Serialize the config JSON and deserialize at runtime.
408            let json_literal = serde_json::to_string(value).unwrap_or_default();
409            let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
410            lines.push(format!(
411                "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
412            ));
413            lines.push(format!(
414                "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
415            ));
416        }
417        return (lines, format!("&{name}"));
418    }
419    if arg_type == "json_object" {
420        return render_json_object_arg(name, value, optional, module);
421    }
422    if value.is_null() && !optional {
423        // Required arg with no fixture value: use a language-appropriate default.
424        let default_val = match arg_type {
425            "string" => "String::new()".to_string(),
426            "int" | "integer" => "0".to_string(),
427            "float" | "number" => "0.0_f64".to_string(),
428            "bool" | "boolean" => "false".to_string(),
429            _ => "Default::default()".to_string(),
430        };
431        // String args are passed by reference in Rust.
432        let expr = if arg_type == "string" {
433            format!("&{name}")
434        } else {
435            name.to_string()
436        };
437        return (vec![format!("let {name} = {default_val};")], expr);
438    }
439    let literal = json_to_rust_literal(value, arg_type);
440    // String args are passed by reference in Rust.
441    let pass_by_ref = arg_type == "string";
442    let expr = |n: &str| if pass_by_ref { format!("&{n}") } else { n.to_string() };
443    if optional && value.is_null() {
444        (vec![format!("let {name} = None;")], expr(name))
445    } else if optional {
446        (vec![format!("let {name} = Some({literal});")], expr(name))
447    } else {
448        (vec![format!("let {name} = {literal};")], expr(name))
449    }
450}
451
452/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
453/// and deserialize it through serde at runtime. Type inference from the function signature
454/// determines the concrete type, keeping the generator generic.
455fn render_json_object_arg(
456    name: &str,
457    value: &serde_json::Value,
458    optional: bool,
459    _module: &str,
460) -> (Vec<String>, String) {
461    if value.is_null() && optional {
462        return (vec![format!("let {name} = None;")], name.to_string());
463    }
464
465    // Fixture keys are camelCase; the Rust ConversionOptions type uses snake_case serde.
466    // Normalize keys before building the json! literal so deserialization succeeds.
467    let normalized = super::normalize_json_keys_to_snake_case(value);
468    // Build the json! macro invocation from the fixture object.
469    let json_literal = json_value_to_macro_literal(&normalized);
470    let mut lines = Vec::new();
471    lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
472    // Deserialize to a concrete type inferred from the function signature.
473    let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
474    if optional {
475        lines.push(format!("let {name} = Some({deser_expr});"));
476    } else {
477        lines.push(format!("let {name} = {deser_expr};"));
478    }
479    (lines, name.to_string())
480}
481
482/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
483fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
484    match value {
485        serde_json::Value::Null => "null".to_string(),
486        serde_json::Value::Bool(b) => format!("{b}"),
487        serde_json::Value::Number(n) => n.to_string(),
488        serde_json::Value::String(s) => {
489            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
490            format!("\"{escaped}\"")
491        }
492        serde_json::Value::Array(arr) => {
493            let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
494            format!("[{}]", items.join(", "))
495        }
496        serde_json::Value::Object(obj) => {
497            let entries: Vec<String> = obj
498                .iter()
499                .map(|(k, v)| {
500                    let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
501                    format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
502                })
503                .collect();
504            format!("{{{}}}", entries.join(", "))
505        }
506    }
507}
508
509fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
510    match value {
511        serde_json::Value::Null => "None".to_string(),
512        serde_json::Value::Bool(b) => format!("{b}"),
513        serde_json::Value::Number(n) => {
514            if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
515                if let Some(f) = n.as_f64() {
516                    return format!("{f}_f64");
517                }
518            }
519            n.to_string()
520        }
521        serde_json::Value::String(s) => rust_raw_string(s),
522        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
523            let json_str = serde_json::to_string(value).unwrap_or_default();
524            let literal = rust_raw_string(&json_str);
525            format!("serde_json::from_str({literal}).unwrap()")
526        }
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Assertion rendering
532// ---------------------------------------------------------------------------
533
534fn render_assertion(
535    out: &mut String,
536    assertion: &Assertion,
537    result_var: &str,
538    _dep_name: &str,
539    is_error_context: bool,
540    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
541    field_resolver: &FieldResolver,
542) {
543    // Skip assertions on fields that don't exist on the result type.
544    if let Some(f) = &assertion.field {
545        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
546            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
547            return;
548        }
549    }
550
551    // Determine field access expression:
552    // 1. If the field was unwrapped to a local var, use that local var name.
553    // 2. Otherwise, use the field resolver to generate the accessor.
554    let field_access = match &assertion.field {
555        Some(f) if !f.is_empty() => {
556            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
557                local_var.clone()
558            } else {
559                field_resolver.accessor(f, "rust", result_var)
560            }
561        }
562        _ => result_var.to_string(),
563    };
564
565    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
566    let is_unwrapped = assertion
567        .field
568        .as_ref()
569        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
570
571    match assertion.assertion_type.as_str() {
572        "error" => {
573            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
574            if let Some(serde_json::Value::String(msg)) = &assertion.value {
575                let escaped = escape_rust(msg);
576                let _ = writeln!(
577                    out,
578                    "    assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
579                );
580            }
581        }
582        "not_error" => {
583            // Handled at call site; nothing extra needed here.
584        }
585        "equals" => {
586            if let Some(val) = &assertion.value {
587                let expected = value_to_rust_string(val);
588                if is_error_context {
589                    return;
590                }
591                // For string equality, trim trailing whitespace to handle trailing newlines
592                // from the converter.
593                if val.is_string() {
594                    let _ = writeln!(
595                        out,
596                        "    assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
597                    );
598                } else if val.is_boolean() {
599                    // Use assert!/assert!(!...) for booleans — clippy prefers this over assert_eq!(_, true/false).
600                    if val.as_bool() == Some(true) {
601                        let _ = writeln!(out, "    assert!({field_access}, \"equals assertion failed\");");
602                    } else {
603                        let _ = writeln!(out, "    assert!(!{field_access}, \"equals assertion failed\");");
604                    }
605                } else {
606                    // Wrap expected value in Some() for optional fields.
607                    let is_opt = assertion.field.as_ref().is_some_and(|f| {
608                        let resolved = field_resolver.resolve(f);
609                        field_resolver.is_optional(resolved)
610                    });
611                    if is_opt
612                        && !unwrapped_fields
613                            .iter()
614                            .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
615                    {
616                        let _ = writeln!(
617                            out,
618                            "    assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
619                        );
620                    } else {
621                        let _ = writeln!(
622                            out,
623                            "    assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
624                        );
625                    }
626                }
627            }
628        }
629        "contains" => {
630            if let Some(val) = &assertion.value {
631                let expected = value_to_rust_string(val);
632                let line = format!(
633                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
634                );
635                let _ = writeln!(out, "{line}");
636            }
637        }
638        "contains_all" => {
639            if let Some(values) = &assertion.values {
640                for val in values {
641                    let expected = value_to_rust_string(val);
642                    let line = format!(
643                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
644                    );
645                    let _ = writeln!(out, "{line}");
646                }
647            }
648        }
649        "not_contains" => {
650            if let Some(val) = &assertion.value {
651                let expected = value_to_rust_string(val);
652                let line = format!(
653                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
654                );
655                let _ = writeln!(out, "{line}");
656            }
657        }
658        "not_empty" => {
659            if let Some(f) = &assertion.field {
660                let resolved = field_resolver.resolve(f);
661                if !is_unwrapped && field_resolver.is_optional(resolved) {
662                    // Non-string optional field (e.g., Option<Struct>): use is_some()
663                    let accessor = field_resolver.accessor(f, "rust", result_var);
664                    let _ = writeln!(
665                        out,
666                        "    assert!({accessor}.is_some(), \"expected {f} to be present\");"
667                    );
668                } else {
669                    let _ = writeln!(
670                        out,
671                        "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
672                    );
673                }
674            } else {
675                let _ = writeln!(
676                    out,
677                    "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
678                );
679            }
680        }
681        "is_empty" => {
682            if let Some(f) = &assertion.field {
683                let resolved = field_resolver.resolve(f);
684                if !is_unwrapped && field_resolver.is_optional(resolved) {
685                    let accessor = field_resolver.accessor(f, "rust", result_var);
686                    let _ = writeln!(out, "    assert!({accessor}.is_none(), \"expected {f} to be absent\");");
687                } else {
688                    let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
689                }
690            } else {
691                let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
692            }
693        }
694        "contains_any" => {
695            if let Some(values) = &assertion.values {
696                let checks: Vec<String> = values
697                    .iter()
698                    .map(|v| {
699                        let expected = value_to_rust_string(v);
700                        format!("{field_access}.contains({expected})")
701                    })
702                    .collect();
703                let joined = checks.join(" || ");
704                let _ = writeln!(
705                    out,
706                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
707                );
708            }
709        }
710        "greater_than" => {
711            if let Some(val) = &assertion.value {
712                // Skip comparisons with negative values against unsigned types (.len() etc.)
713                if val.as_f64().is_some_and(|n| n < 0.0) {
714                    let _ = writeln!(
715                        out,
716                        "    // skipped: greater_than with negative value is always true for unsigned types"
717                    );
718                } else if val.as_u64() == Some(0) {
719                    // Clippy prefers !is_empty() over len() > 0
720                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
721                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
722                } else {
723                    let lit = numeric_literal(val);
724                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
725                }
726            }
727        }
728        "less_than" => {
729            if let Some(val) = &assertion.value {
730                let lit = numeric_literal(val);
731                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
732            }
733        }
734        "greater_than_or_equal" => {
735            if let Some(val) = &assertion.value {
736                if val.as_u64() == Some(1) {
737                    // Clippy prefers !is_empty() over len() >= 1
738                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
739                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= 1\");");
740                } else {
741                    let lit = numeric_literal(val);
742                    let _ = writeln!(out, "    assert!({field_access} >= {lit}, \"expected >= {lit}\");");
743                }
744            }
745        }
746        "less_than_or_equal" => {
747            if let Some(val) = &assertion.value {
748                let lit = numeric_literal(val);
749                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
750            }
751        }
752        "starts_with" => {
753            if let Some(val) = &assertion.value {
754                let expected = value_to_rust_string(val);
755                let _ = writeln!(
756                    out,
757                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
758                );
759            }
760        }
761        "ends_with" => {
762            if let Some(val) = &assertion.value {
763                let expected = value_to_rust_string(val);
764                let _ = writeln!(
765                    out,
766                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
767                );
768            }
769        }
770        "min_length" => {
771            if let Some(val) = &assertion.value {
772                if let Some(n) = val.as_u64() {
773                    let _ = writeln!(
774                        out,
775                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
776                    );
777                }
778            }
779        }
780        "max_length" => {
781            if let Some(val) = &assertion.value {
782                if let Some(n) = val.as_u64() {
783                    let _ = writeln!(
784                        out,
785                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
786                    );
787                }
788            }
789        }
790        "count_min" => {
791            if let Some(val) = &assertion.value {
792                if let Some(n) = val.as_u64() {
793                    if n <= 1 {
794                        // Clippy prefers !is_empty() over len() >= 1
795                        let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
796                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= {n}\");");
797                    } else {
798                        let _ = writeln!(
799                            out,
800                            "    assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
801                        );
802                    }
803                }
804            }
805        }
806        other => {
807            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
808        }
809    }
810}
811
812/// Convert a JSON numeric value to a Rust literal suitable for comparisons.
813///
814/// Whole numbers (no fractional part) are emitted as bare integer literals so
815/// they are compatible with `usize`, `u64`, etc. (e.g., `.len()` results).
816/// Numbers with a fractional component get the `_f64` suffix.
817fn numeric_literal(value: &serde_json::Value) -> String {
818    if let Some(n) = value.as_f64() {
819        if n.fract() == 0.0 {
820            // Whole number — emit without a type suffix so Rust can infer the
821            // correct integer type from context (usize, u64, i64, …).
822            return format!("{}", n as i64);
823        }
824        return format!("{n}_f64");
825    }
826    // Fallback: use the raw JSON representation.
827    value.to_string()
828}
829
830fn value_to_rust_string(value: &serde_json::Value) -> String {
831    match value {
832        serde_json::Value::String(s) => rust_raw_string(s),
833        serde_json::Value::Bool(b) => format!("{b}"),
834        serde_json::Value::Number(n) => n.to_string(),
835        other => {
836            let s = other.to_string();
837            format!("\"{s}\"")
838        }
839    }
840}