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