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