Skip to main content

alef_e2e/codegen/
c.rs

1//! C e2e test generator using assert.h and a Makefile.
2//!
3//! Generates `e2e/c/Makefile`, per-category `test_{category}.c` files,
4//! a `main.c` test runner, and a `test_runner.h` header.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_c, 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 heck::{ToPascalCase, ToSnakeCase};
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// C e2e code generator.
21pub struct CCodegen;
22
23impl E2eCodegen for CCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        _alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(&e2e_config.output).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.clone());
42        let result_var = &call.result_var;
43        let prefix = overrides.and_then(|o| o.prefix.as_ref()).cloned().unwrap_or_default();
44        let header = overrides
45            .and_then(|o| o.header.as_ref())
46            .cloned()
47            .unwrap_or_else(|| format!("{}.h", call.module));
48
49        // Resolve package config.
50        let c_pkg = e2e_config.packages.get("c");
51        let include_path = c_pkg
52            .and_then(|p| p.path.as_ref())
53            .map(|p| format!("{p}/include"))
54            .unwrap_or_else(|| "../../crates/ffi/include".to_string());
55        let lib_path = c_pkg
56            .and_then(|p| p.module.as_ref())
57            .cloned()
58            .unwrap_or_else(|| "../../target/release".to_string());
59        let lib_name = c_pkg
60            .and_then(|p| p.name.as_ref())
61            .cloned()
62            .unwrap_or_else(|| call.module.clone());
63
64        // Filter active groups (with non-skipped fixtures).
65        let active_groups: Vec<(&FixtureGroup, Vec<&Fixture>)> = groups
66            .iter()
67            .filter_map(|group| {
68                let active: Vec<&Fixture> = group
69                    .fixtures
70                    .iter()
71                    .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
72                    .collect();
73                if active.is_empty() { None } else { Some((group, active)) }
74            })
75            .collect();
76
77        // Generate Makefile.
78        let category_names: Vec<String> = active_groups
79            .iter()
80            .map(|(g, _)| sanitize_filename(&g.category))
81            .collect();
82        files.push(GeneratedFile {
83            path: output_base.join("Makefile"),
84            content: render_makefile(&category_names, &include_path, &lib_path, &lib_name),
85            generated_header: true,
86        });
87
88        // Generate test_runner.h.
89        files.push(GeneratedFile {
90            path: output_base.join("test_runner.h"),
91            content: render_test_runner_header(&active_groups),
92            generated_header: true,
93        });
94
95        // Generate main.c.
96        files.push(GeneratedFile {
97            path: output_base.join("main.c"),
98            content: render_main_c(&active_groups),
99            generated_header: true,
100        });
101
102        let field_resolver = FieldResolver::new(
103            &e2e_config.fields,
104            &e2e_config.fields_optional,
105            &e2e_config.result_fields,
106            &e2e_config.fields_array,
107        );
108
109        // Generate per-category test files.
110        for (group, active) in &active_groups {
111            let filename = format!("test_{}.c", sanitize_filename(&group.category));
112            let content = render_test_file(
113                &group.category,
114                active,
115                &header,
116                &prefix,
117                &function_name,
118                result_var,
119                &e2e_config.call.args,
120                &field_resolver,
121                &e2e_config.fields_c_types,
122            );
123            files.push(GeneratedFile {
124                path: output_base.join(filename),
125                content,
126                generated_header: true,
127            });
128        }
129
130        Ok(files)
131    }
132
133    fn language_name(&self) -> &'static str {
134        "c"
135    }
136}
137
138fn render_makefile(categories: &[String], include_path: &str, lib_path: &str, lib_name: &str) -> String {
139    let mut out = String::new();
140    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
141    let _ = writeln!(out, "CC = gcc");
142    let _ = writeln!(out, "CFLAGS = -Wall -Wextra -I{include_path}");
143    let _ = writeln!(out, "LDFLAGS = -L{lib_path} -l{lib_name}");
144    let _ = writeln!(out);
145
146    let src_files: Vec<String> = categories.iter().map(|c| format!("test_{c}.c")).collect();
147    let srcs = src_files.join(" ");
148
149    let _ = writeln!(out, "SRCS = main.c {srcs}");
150    let _ = writeln!(out, "TARGET = run_tests");
151    let _ = writeln!(out);
152    let _ = writeln!(out, ".PHONY: all clean test");
153    let _ = writeln!(out);
154    let _ = writeln!(out, "all: $(TARGET)");
155    let _ = writeln!(out);
156    let _ = writeln!(out, "$(TARGET): $(SRCS)");
157    let _ = writeln!(out, "\t$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)");
158    let _ = writeln!(out);
159    let _ = writeln!(out, "test: $(TARGET)");
160    let _ = writeln!(out, "\t./$(TARGET)");
161    let _ = writeln!(out);
162    let _ = writeln!(out, "clean:");
163    let _ = writeln!(out, "\trm -f $(TARGET)");
164    out
165}
166
167fn render_test_runner_header(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
168    let mut out = String::new();
169    let _ = writeln!(out, "/* This file is auto-generated by alef. DO NOT EDIT. */");
170    let _ = writeln!(out, "#ifndef TEST_RUNNER_H");
171    let _ = writeln!(out, "#define TEST_RUNNER_H");
172    let _ = writeln!(out);
173    let _ = writeln!(out, "#include <string.h>");
174    let _ = writeln!(out, "#include <stdlib.h>");
175    let _ = writeln!(out);
176    // Trim helper for comparing strings that may have trailing whitespace/newlines.
177    let _ = writeln!(out, "/**");
178    let _ = writeln!(
179        out,
180        " * Compare a string against an expected value, trimming trailing whitespace."
181    );
182    let _ = writeln!(
183        out,
184        " * Returns 0 if the trimmed actual string equals the expected string."
185    );
186    let _ = writeln!(out, " */");
187    let _ = writeln!(
188        out,
189        "static inline int str_trim_eq(const char *actual, const char *expected) {{"
190    );
191    let _ = writeln!(
192        out,
193        "    if (actual == NULL || expected == NULL) return actual != expected;"
194    );
195    let _ = writeln!(out, "    size_t alen = strlen(actual);");
196    let _ = writeln!(
197        out,
198        "    while (alen > 0 && (actual[alen-1] == ' ' || actual[alen-1] == '\\n' || actual[alen-1] == '\\r' || actual[alen-1] == '\\t')) alen--;"
199    );
200    let _ = writeln!(out, "    size_t elen = strlen(expected);");
201    let _ = writeln!(out, "    if (alen != elen) return 1;");
202    let _ = writeln!(out, "    return memcmp(actual, expected, elen);");
203    let _ = writeln!(out, "}}");
204    let _ = writeln!(out);
205
206    let _ = writeln!(out, "/**");
207    let _ = writeln!(
208        out,
209        " * Extract a string value for a given key from a JSON object string."
210    );
211    let _ = writeln!(
212        out,
213        " * Returns a heap-allocated copy of the value, or NULL if not found."
214    );
215    let _ = writeln!(out, " * Caller must free() the returned string.");
216    let _ = writeln!(out, " */");
217    let _ = writeln!(
218        out,
219        "static inline char *htm_json_get_string(const char *json, const char *key) {{"
220    );
221    let _ = writeln!(out, "    if (json == NULL || key == NULL) return NULL;");
222    let _ = writeln!(out, "    /* Build search pattern: \"key\":  */");
223    let _ = writeln!(out, "    size_t key_len = strlen(key);");
224    let _ = writeln!(out, "    char *pattern = (char *)malloc(key_len + 5);");
225    let _ = writeln!(out, "    if (!pattern) return NULL;");
226    let _ = writeln!(out, "    pattern[0] = '\"';");
227    let _ = writeln!(out, "    memcpy(pattern + 1, key, key_len);");
228    let _ = writeln!(out, "    pattern[key_len + 1] = '\"';");
229    let _ = writeln!(out, "    pattern[key_len + 2] = ':';");
230    let _ = writeln!(out, "    pattern[key_len + 3] = '\\0';");
231    let _ = writeln!(out, "    const char *found = strstr(json, pattern);");
232    let _ = writeln!(out, "    free(pattern);");
233    let _ = writeln!(out, "    if (!found) return NULL;");
234    let _ = writeln!(out, "    found += key_len + 3; /* skip past \"key\": */");
235    let _ = writeln!(out, "    while (*found == ' ' || *found == '\\t') found++;");
236    let _ = writeln!(out, "    if (*found != '\"') return NULL; /* not a string value */");
237    let _ = writeln!(out, "    found++; /* skip opening quote */");
238    let _ = writeln!(out, "    const char *end = found;");
239    let _ = writeln!(out, "    while (*end && *end != '\"') {{");
240    let _ = writeln!(out, "        if (*end == '\\\\') {{ end++; if (*end) end++; }}");
241    let _ = writeln!(out, "        else end++;");
242    let _ = writeln!(out, "    }}");
243    let _ = writeln!(out, "    size_t val_len = (size_t)(end - found);");
244    let _ = writeln!(out, "    char *result_str = (char *)malloc(val_len + 1);");
245    let _ = writeln!(out, "    if (!result_str) return NULL;");
246    let _ = writeln!(out, "    memcpy(result_str, found, val_len);");
247    let _ = writeln!(out, "    result_str[val_len] = '\\0';");
248    let _ = writeln!(out, "    return result_str;");
249    let _ = writeln!(out, "}}");
250    let _ = writeln!(out);
251    let _ = writeln!(out, "/**");
252    let _ = writeln!(out, " * Count top-level elements in a JSON array string.");
253    let _ = writeln!(out, " * Returns 0 for empty arrays (\"[]\") or NULL input.");
254    let _ = writeln!(out, " */");
255    let _ = writeln!(out, "static inline int htm_json_array_count(const char *json) {{");
256    let _ = writeln!(out, "    if (json == NULL) return 0;");
257    let _ = writeln!(out, "    /* Skip leading whitespace */");
258    let _ = writeln!(
259        out,
260        "    while (*json == ' ' || *json == '\\t' || *json == '\\n') json++;"
261    );
262    let _ = writeln!(out, "    if (*json != '[') return 0;");
263    let _ = writeln!(out, "    json++;");
264    let _ = writeln!(out, "    /* Skip whitespace after '[' */");
265    let _ = writeln!(
266        out,
267        "    while (*json == ' ' || *json == '\\t' || *json == '\\n') json++;"
268    );
269    let _ = writeln!(out, "    if (*json == ']') return 0;");
270    let _ = writeln!(out, "    int count = 1;");
271    let _ = writeln!(out, "    int depth = 0;");
272    let _ = writeln!(out, "    int in_string = 0;");
273    let _ = writeln!(
274        out,
275        "    for (; *json && !(*json == ']' && depth == 0 && !in_string); json++) {{"
276    );
277    let _ = writeln!(out, "        if (*json == '\\\\' && in_string) {{ json++; continue; }}");
278    let _ = writeln!(
279        out,
280        "        if (*json == '\"') {{ in_string = !in_string; continue; }}"
281    );
282    let _ = writeln!(out, "        if (in_string) continue;");
283    let _ = writeln!(out, "        if (*json == '[' || *json == '{{') depth++;");
284    let _ = writeln!(out, "        else if (*json == ']' || *json == '}}') depth--;");
285    let _ = writeln!(out, "        else if (*json == ',' && depth == 0) count++;");
286    let _ = writeln!(out, "    }}");
287    let _ = writeln!(out, "    return count;");
288    let _ = writeln!(out, "}}");
289    let _ = writeln!(out);
290
291    for (group, fixtures) in active_groups {
292        let _ = writeln!(out, "/* Tests for category: {} */", group.category);
293        for fixture in fixtures {
294            let fn_name = sanitize_ident(&fixture.id);
295            let _ = writeln!(out, "void test_{fn_name}(void);");
296        }
297        let _ = writeln!(out);
298    }
299
300    let _ = writeln!(out, "#endif /* TEST_RUNNER_H */");
301    out
302}
303
304fn render_main_c(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
305    let mut out = String::new();
306    let _ = writeln!(out, "/* This file is auto-generated by alef. DO NOT EDIT. */");
307    let _ = writeln!(out, "#include <stdio.h>");
308    let _ = writeln!(out, "#include \"test_runner.h\"");
309    let _ = writeln!(out);
310    let _ = writeln!(out, "int main(void) {{");
311    let _ = writeln!(out, "    int passed = 0;");
312    let _ = writeln!(out, "    int failed = 0;");
313    let _ = writeln!(out);
314
315    for (group, fixtures) in active_groups {
316        let _ = writeln!(out, "    /* Category: {} */", group.category);
317        for fixture in fixtures {
318            let fn_name = sanitize_ident(&fixture.id);
319            let _ = writeln!(out, "    printf(\"  Running test_{fn_name}...\");");
320            let _ = writeln!(out, "    test_{fn_name}();");
321            let _ = writeln!(out, "    printf(\" PASSED\\n\");");
322            let _ = writeln!(out, "    passed++;");
323        }
324        let _ = writeln!(out);
325    }
326
327    let _ = writeln!(
328        out,
329        "    printf(\"\\nResults: %d passed, %d failed\\n\", passed, failed);"
330    );
331    let _ = writeln!(out, "    return failed > 0 ? 1 : 0;");
332    let _ = writeln!(out, "}}");
333    out
334}
335
336#[allow(clippy::too_many_arguments)]
337fn render_test_file(
338    category: &str,
339    fixtures: &[&Fixture],
340    header: &str,
341    prefix: &str,
342    function_name: &str,
343    result_var: &str,
344    args: &[crate::config::ArgMapping],
345    field_resolver: &FieldResolver,
346    fields_c_types: &HashMap<String, String>,
347) -> String {
348    let mut out = String::new();
349    let _ = writeln!(out, "/* This file is auto-generated by alef. DO NOT EDIT. */");
350    let _ = writeln!(out, "/* E2e tests for category: {category} */");
351    let _ = writeln!(out);
352    let _ = writeln!(out, "#include <assert.h>");
353    let _ = writeln!(out, "#include <string.h>");
354    let _ = writeln!(out, "#include <stdio.h>");
355    let _ = writeln!(out, "#include <stdlib.h>");
356    let _ = writeln!(out, "#include \"{header}\"");
357    let _ = writeln!(out, "#include \"test_runner.h\"");
358    let _ = writeln!(out);
359
360    for (i, fixture) in fixtures.iter().enumerate() {
361        render_test_function(
362            &mut out,
363            fixture,
364            prefix,
365            function_name,
366            result_var,
367            args,
368            field_resolver,
369            fields_c_types,
370        );
371        if i + 1 < fixtures.len() {
372            let _ = writeln!(out);
373        }
374    }
375
376    out
377}
378
379#[allow(clippy::too_many_arguments)]
380fn render_test_function(
381    out: &mut String,
382    fixture: &Fixture,
383    prefix: &str,
384    function_name: &str,
385    result_var: &str,
386    args: &[crate::config::ArgMapping],
387    field_resolver: &FieldResolver,
388    fields_c_types: &HashMap<String, String>,
389) {
390    let fn_name = sanitize_ident(&fixture.id);
391    let description = &fixture.description;
392
393    // Use the function name directly — the override already includes the prefix
394    // (e.g. "htm_convert"), so we must NOT prepend it again.
395    let prefixed_fn = function_name.to_string();
396
397    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
398
399    let _ = writeln!(out, "void test_{fn_name}(void) {{");
400    let _ = writeln!(out, "    /* {description} */");
401
402    // For json_object args, emit a from_json call to construct the options handle.
403    let mut has_options_handle = false;
404    for arg in args {
405        if arg.arg_type == "json_object" {
406            if let Some(val) = fixture.input.get(&arg.field) {
407                if !val.is_null() {
408                    // Fixture keys are camelCase; the FFI htm_conversion_options_from_json
409                    // deserializes into the Rust ConversionOptions type which uses default
410                    // serde (snake_case). Normalize keys before serializing.
411                    let normalized = super::normalize_json_keys_to_snake_case(val);
412                    let json_str = serde_json::to_string(&normalized).unwrap_or_default();
413                    let escaped = escape_c(&json_str);
414                    let upper = prefix.to_uppercase();
415                    let _ = writeln!(
416                        out,
417                        "    {upper}ConversionOptions* options_handle = {prefix}_conversion_options_from_json(\"{escaped}\");"
418                    );
419                    has_options_handle = true;
420                }
421            }
422        }
423    }
424
425    let args_str = build_args_string_c(&fixture.input, args, has_options_handle);
426
427    if expects_error {
428        let _ = writeln!(
429            out,
430            "    HTMConversionResult* {result_var} = {prefixed_fn}({args_str});"
431        );
432        if has_options_handle {
433            let _ = writeln!(out, "    {prefix}_conversion_options_free(options_handle);");
434        }
435        let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
436        let _ = writeln!(out, "}}");
437        return;
438    }
439
440    // The FFI returns an opaque handle; extract the content string from it.
441    let _ = writeln!(
442        out,
443        "    HTMConversionResult* {result_var} = {prefixed_fn}({args_str});"
444    );
445    let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
446
447    // Collect fields accessed by assertions so we can emit accessor calls.
448    // C FFI uses the opaque handle pattern: {prefix}_conversion_result_{field}(handle).
449    // For nested paths we generate chained FFI accessor calls using the type
450    // chain from `fields_c_types`.
451    // Each entry: (fixture_field, local_var, from_json_extract).
452    // `from_json_extract` is true when the variable was extracted from a JSON
453    // map via htm_json_get_string and needs free() instead of {prefix}_free_string().
454    let mut accessed_fields: Vec<(String, String, bool)> = Vec::new();
455    // Track intermediate handles emitted so we can free them and avoid duplicates.
456    // Each entry: (handle_var_name, snake_type_name) — freed in reverse order.
457    let mut intermediate_handles: Vec<(String, String)> = Vec::new();
458
459    for assertion in &fixture.assertions {
460        if let Some(f) = &assertion.field {
461            if !f.is_empty() && !accessed_fields.iter().any(|(k, _, _)| k == f) {
462                let resolved = field_resolver.resolve(f);
463                let local_var = f.replace(['.', '['], "_").replace(']', "");
464                let has_map_access = resolved.contains('[');
465
466                if resolved.contains('.') {
467                    emit_nested_accessor(
468                        out,
469                        prefix,
470                        resolved,
471                        &local_var,
472                        result_var,
473                        fields_c_types,
474                        &mut intermediate_handles,
475                    );
476                } else {
477                    let accessor_fn = format!("{prefix}_conversion_result_{resolved}");
478                    let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({result_var});");
479                }
480                accessed_fields.push((f.clone(), local_var.clone(), has_map_access));
481            }
482        }
483    }
484
485    for assertion in &fixture.assertions {
486        render_assertion(out, assertion, result_var, field_resolver, &accessed_fields);
487    }
488
489    // Free extracted leaf strings.
490    for (_f, local_var, from_json) in &accessed_fields {
491        if *from_json {
492            let _ = writeln!(out, "    free({local_var});");
493        } else {
494            let _ = writeln!(out, "    {prefix}_free_string({local_var});");
495        }
496    }
497    // Free intermediate handles in reverse order.
498    for (handle_var, snake_type) in intermediate_handles.iter().rev() {
499        if snake_type == "free_string" {
500            // free_string handles are freed with the free_string function directly.
501            let _ = writeln!(out, "    {prefix}_free_string({handle_var});");
502        } else {
503            let _ = writeln!(out, "    {prefix}_{snake_type}_free({handle_var});");
504        }
505    }
506    if has_options_handle {
507        let _ = writeln!(out, "    {prefix}_conversion_options_free(options_handle);");
508    }
509    let _ = writeln!(out, "    {prefix}_conversion_result_free({result_var});");
510    let _ = writeln!(out, "}}");
511}
512
513/// Emit chained FFI accessor calls for a nested resolved field path.
514///
515/// For a path like `metadata.document.title`, this generates:
516/// ```c
517/// HTMHtmlMetadata* metadata_handle = htm_conversion_result_metadata(result);
518/// assert(metadata_handle != NULL);
519/// HTMDocumentMetadata* doc_handle = htm_html_metadata_document(metadata_handle);
520/// assert(doc_handle != NULL);
521/// char* metadata_title = htm_document_metadata_title(doc_handle);
522/// ```
523///
524/// The type chain is looked up from `fields_c_types` which maps
525/// `"{parent_snake_type}.{field}"` -> `"PascalCaseType"`.
526fn emit_nested_accessor(
527    out: &mut String,
528    prefix: &str,
529    resolved: &str,
530    local_var: &str,
531    result_var: &str,
532    fields_c_types: &HashMap<String, String>,
533    intermediate_handles: &mut Vec<(String, String)>,
534) {
535    let segments: Vec<&str> = resolved.split('.').collect();
536    let prefix_upper = prefix.to_uppercase();
537
538    // Walk the path, starting from the root type `conversion_result`.
539    let mut current_snake_type = "conversion_result".to_string();
540    let mut current_handle = result_var.to_string();
541
542    for (i, segment) in segments.iter().enumerate() {
543        let is_leaf = i + 1 == segments.len();
544
545        // Check for map access: "field[key]"
546        if let Some(bracket_pos) = segment.find('[') {
547            let field_name = &segment[..bracket_pos];
548            let key = segment[bracket_pos + 1..].trim_end_matches(']');
549            let field_snake = field_name.to_snake_case();
550            let accessor_fn = format!("{prefix}_{current_snake_type}_{field_snake}");
551
552            // The map accessor returns a char* (JSON object string).
553            // Use htm_json_get_string to extract the key value.
554            let json_var = format!("{field_snake}_json");
555            if !intermediate_handles.iter().any(|(h, _)| h == &json_var) {
556                let _ = writeln!(out, "    char* {json_var} = {accessor_fn}({current_handle});");
557                let _ = writeln!(out, "    assert({json_var} != NULL);");
558                // Track for freeing — use prefix_free_string since it's a char*.
559                intermediate_handles.push((json_var.clone(), "free_string".to_string()));
560            }
561            // Extract the key from the JSON map.
562            let _ = writeln!(
563                out,
564                "    char* {local_var} = htm_json_get_string({json_var}, \"{key}\");"
565            );
566            return; // Map access is always the leaf.
567        }
568
569        let seg_snake = segment.to_snake_case();
570        let accessor_fn = format!("{prefix}_{current_snake_type}_{seg_snake}");
571
572        if is_leaf {
573            // Leaf field returns char* — assign to the local variable.
574            let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({current_handle});");
575        } else {
576            // Intermediate field returns an opaque handle.
577            let lookup_key = format!("{current_snake_type}.{seg_snake}");
578            let return_type_pascal = match fields_c_types.get(&lookup_key) {
579                Some(t) => t.clone(),
580                None => {
581                    // Fallback: derive PascalCase from the segment name itself.
582                    segment.to_pascal_case()
583                }
584            };
585            let return_snake = return_type_pascal.to_snake_case();
586            let handle_var = format!("{seg_snake}_handle");
587
588            // Only emit the handle if we haven't already (multiple fields may
589            // share the same intermediate path prefix).
590            if !intermediate_handles.iter().any(|(h, _)| h == &handle_var) {
591                let _ = writeln!(
592                    out,
593                    "    {prefix_upper}{return_type_pascal}* {handle_var} = \
594                     {accessor_fn}({current_handle});"
595                );
596                let _ = writeln!(out, "    assert({handle_var} != NULL);");
597                intermediate_handles.push((handle_var.clone(), return_snake.clone()));
598            }
599
600            current_snake_type = return_snake;
601            current_handle = handle_var;
602        }
603    }
604}
605
606/// Build the C argument string for the function call.
607/// When `has_options_handle` is true, json_object args are replaced with
608/// the `options_handle` pointer (which was constructed via `from_json`).
609fn build_args_string_c(
610    input: &serde_json::Value,
611    args: &[crate::config::ArgMapping],
612    has_options_handle: bool,
613) -> String {
614    if args.is_empty() {
615        return json_to_c(input);
616    }
617
618    let parts: Vec<String> = args
619        .iter()
620        .filter_map(|arg| {
621            let val = input.get(&arg.field);
622            match val {
623                // Field missing entirely and optional → pass NULL.
624                None if arg.optional => Some("NULL".to_string()),
625                // Field missing and required → skip (caller error, but don't crash).
626                None => None,
627                // Explicit null on optional arg → pass NULL.
628                Some(v) if v.is_null() && arg.optional => Some("NULL".to_string()),
629                Some(v) => {
630                    // For json_object args, use the options_handle pointer
631                    // instead of the raw JSON string.
632                    if arg.arg_type == "json_object" && has_options_handle && !v.is_null() {
633                        Some("options_handle".to_string())
634                    } else {
635                        Some(json_to_c(v))
636                    }
637                }
638            }
639        })
640        .collect();
641
642    parts.join(", ")
643}
644
645fn render_assertion(
646    out: &mut String,
647    assertion: &Assertion,
648    result_var: &str,
649    _field_resolver: &FieldResolver,
650    accessed_fields: &[(String, String, bool)],
651) {
652    // Skip assertions on fields that don't exist on the result type.
653    if let Some(f) = &assertion.field {
654        if !f.is_empty() && !_field_resolver.is_valid_for_result(f) {
655            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
656            return;
657        }
658    }
659
660    let field_expr = match &assertion.field {
661        Some(f) if !f.is_empty() => {
662            // Use the local variable extracted from the opaque handle.
663            accessed_fields
664                .iter()
665                .find(|(k, _, _)| k == f)
666                .map(|(_, local, _)| local.clone())
667                .unwrap_or_else(|| result_var.to_string())
668        }
669        _ => result_var.to_string(),
670    };
671
672    match assertion.assertion_type.as_str() {
673        "equals" => {
674            if let Some(expected) = &assertion.value {
675                let c_val = json_to_c(expected);
676                if expected.is_string() {
677                    // Use str_trim_eq for string comparisons to handle trailing whitespace.
678                    let _ = writeln!(
679                        out,
680                        "    assert(str_trim_eq({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
681                    );
682                } else {
683                    let _ = writeln!(
684                        out,
685                        "    assert(strcmp({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
686                    );
687                }
688            }
689        }
690        "contains" => {
691            if let Some(expected) = &assertion.value {
692                let c_val = json_to_c(expected);
693                let _ = writeln!(
694                    out,
695                    "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
696                );
697            }
698        }
699        "contains_all" => {
700            if let Some(values) = &assertion.values {
701                for val in values {
702                    let c_val = json_to_c(val);
703                    let _ = writeln!(
704                        out,
705                        "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
706                    );
707                }
708            }
709        }
710        "not_contains" => {
711            if let Some(expected) = &assertion.value {
712                let c_val = json_to_c(expected);
713                let _ = writeln!(
714                    out,
715                    "    assert(strstr({field_expr}, {c_val}) == NULL && \"expected NOT to contain substring\");"
716                );
717            }
718        }
719        "not_empty" => {
720            let _ = writeln!(
721                out,
722                "    assert(strlen({field_expr}) > 0 && \"expected non-empty value\");"
723            );
724        }
725        "is_empty" => {
726            let _ = writeln!(
727                out,
728                "    assert(strlen({field_expr}) == 0 && \"expected empty value\");"
729            );
730        }
731        "contains_any" => {
732            if let Some(values) = &assertion.values {
733                let _ = writeln!(out, "    {{");
734                let _ = writeln!(out, "        int found = 0;");
735                for val in values {
736                    let c_val = json_to_c(val);
737                    let _ = writeln!(
738                        out,
739                        "        if (strstr({field_expr}, {c_val}) != NULL) {{ found = 1; }}"
740                    );
741                }
742                let _ = writeln!(
743                    out,
744                    "        assert(found && \"expected to contain at least one of the specified values\");"
745                );
746                let _ = writeln!(out, "    }}");
747            }
748        }
749        "greater_than" => {
750            if let Some(val) = &assertion.value {
751                let c_val = json_to_c(val);
752                let _ = writeln!(out, "    assert({field_expr} > {c_val} && \"expected greater than\");");
753            }
754        }
755        "less_than" => {
756            if let Some(val) = &assertion.value {
757                let c_val = json_to_c(val);
758                let _ = writeln!(out, "    assert({field_expr} < {c_val} && \"expected less than\");");
759            }
760        }
761        "greater_than_or_equal" => {
762            if let Some(val) = &assertion.value {
763                let c_val = json_to_c(val);
764                let _ = writeln!(
765                    out,
766                    "    assert({field_expr} >= {c_val} && \"expected greater than or equal\");"
767                );
768            }
769        }
770        "less_than_or_equal" => {
771            if let Some(val) = &assertion.value {
772                let c_val = json_to_c(val);
773                let _ = writeln!(
774                    out,
775                    "    assert({field_expr} <= {c_val} && \"expected less than or equal\");"
776                );
777            }
778        }
779        "starts_with" => {
780            if let Some(expected) = &assertion.value {
781                let c_val = json_to_c(expected);
782                let _ = writeln!(
783                    out,
784                    "    assert(strncmp({field_expr}, {c_val}, strlen({c_val})) == 0 && \"expected to start with\");"
785                );
786            }
787        }
788        "ends_with" => {
789            if let Some(expected) = &assertion.value {
790                let c_val = json_to_c(expected);
791                let _ = writeln!(out, "    assert(strlen({field_expr}) >= strlen({c_val}) && ");
792                let _ = writeln!(
793                    out,
794                    "           strcmp({field_expr} + strlen({field_expr}) - strlen({c_val}), {c_val}) == 0 && \"expected to end with\");"
795                );
796            }
797        }
798        "min_length" => {
799            if let Some(val) = &assertion.value {
800                if let Some(n) = val.as_u64() {
801                    let _ = writeln!(
802                        out,
803                        "    assert(strlen({field_expr}) >= {n} && \"expected minimum length\");"
804                    );
805                }
806            }
807        }
808        "max_length" => {
809            if let Some(val) = &assertion.value {
810                if let Some(n) = val.as_u64() {
811                    let _ = writeln!(
812                        out,
813                        "    assert(strlen({field_expr}) <= {n} && \"expected maximum length\");"
814                    );
815                }
816            }
817        }
818        "count_min" => {
819            if let Some(val) = &assertion.value {
820                if let Some(n) = val.as_u64() {
821                    let _ = writeln!(out, "    {{");
822                    let _ = writeln!(out, "        /* count_min: count top-level JSON array elements */");
823                    let _ = writeln!(
824                        out,
825                        "        assert({field_expr} != NULL && \"expected non-null collection JSON\");"
826                    );
827                    let _ = writeln!(out, "        int elem_count = htm_json_array_count({field_expr});");
828                    let _ = writeln!(
829                        out,
830                        "        assert(elem_count >= {n} && \"expected at least {n} elements\");"
831                    );
832                    let _ = writeln!(out, "    }}");
833                }
834            }
835        }
836        "not_error" => {
837            // Already handled — the NULL check above covers this.
838        }
839        "error" => {
840            // Handled at the test function level.
841        }
842        other => {
843            let _ = writeln!(out, "    /* TODO: unsupported assertion type: {other} */");
844        }
845    }
846}
847
848/// Convert a `serde_json::Value` to a C literal string.
849fn json_to_c(value: &serde_json::Value) -> String {
850    match value {
851        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
852        serde_json::Value::Bool(true) => "1".to_string(),
853        serde_json::Value::Bool(false) => "0".to_string(),
854        serde_json::Value::Number(n) => n.to_string(),
855        serde_json::Value::Null => "NULL".to_string(),
856        other => format!("\"{}\"", escape_c(&other.to_string())),
857    }
858}