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