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::{CallConfig, 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::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use anyhow::Result;
16use heck::{ToPascalCase, ToSnakeCase};
17use std::collections::HashMap;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22
23/// C e2e code generator.
24pub struct CCodegen;
25
26impl E2eCodegen for CCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        config: &ResolvedCrateConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36        let mut files = Vec::new();
37
38        // Resolve default call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let result_var = &call.result_var;
42        let prefix = overrides
43            .and_then(|o| o.prefix.as_ref())
44            .cloned()
45            .or_else(|| config.ffi.as_ref().and_then(|ffi| ffi.prefix.as_ref()).cloned())
46            .unwrap_or_default();
47        let header = overrides
48            .and_then(|o| o.header.as_ref())
49            .cloned()
50            .unwrap_or_else(|| config.ffi_header_name());
51
52        // Resolve package config.
53        let c_pkg = e2e_config.resolve_package("c");
54        let lib_name = c_pkg
55            .as_ref()
56            .and_then(|p| p.name.as_ref())
57            .cloned()
58            .unwrap_or_else(|| config.ffi_lib_name());
59
60        // Filter active groups (with non-skipped fixtures).
61        let active_groups: Vec<(&FixtureGroup, Vec<&Fixture>)> = groups
62            .iter()
63            .filter_map(|group| {
64                let active: Vec<&Fixture> = group
65                    .fixtures
66                    .iter()
67                    .filter(|f| super::should_include_fixture(f, lang, e2e_config))
68                    .filter(|f| f.visitor.is_none())
69                    .collect();
70                if active.is_empty() { None } else { Some((group, active)) }
71            })
72            .collect();
73
74        // Resolve FFI crate path for local repo builds.
75        // Default to `../../crates/{name}-ffi` derived from the crate name so that
76        // projects like `liter-llm` resolve to `../../crates/liter-llm-ffi/include/`
77        // rather than the generic (incorrect) `../../crates/ffi`.
78        // When `[crates.output] ffi` is set explicitly, derive the crate path from
79        // that value so that renamed FFI crates (e.g. `ts-pack-core-ffi`) resolve
80        // correctly without any hardcoded special cases.
81        let ffi_crate_path = c_pkg
82            .as_ref()
83            .and_then(|p| p.path.as_ref())
84            .cloned()
85            .unwrap_or_else(|| config.ffi_crate_path());
86
87        // Generate Makefile.
88        let category_names: Vec<String> = active_groups
89            .iter()
90            .map(|(g, _)| sanitize_filename(&g.category))
91            .collect();
92        files.push(GeneratedFile {
93            path: output_base.join("Makefile"),
94            content: render_makefile(&category_names, &header, &ffi_crate_path, &lib_name),
95            generated_header: true,
96        });
97
98        // Generate download_ffi.sh for downloading prebuilt FFI from GitHub releases.
99        let github_repo = config.github_repo();
100        let version = config.resolved_version().unwrap_or_else(|| "0.0.0".to_string());
101        let ffi_pkg_name = e2e_config
102            .registry
103            .packages
104            .get("c")
105            .and_then(|p| p.name.as_ref())
106            .cloned()
107            .unwrap_or_else(|| lib_name.clone());
108        files.push(GeneratedFile {
109            path: output_base.join("download_ffi.sh"),
110            content: render_download_script(&github_repo, &version, &ffi_pkg_name),
111            generated_header: true,
112        });
113
114        // Generate test_runner.h.
115        files.push(GeneratedFile {
116            path: output_base.join("test_runner.h"),
117            content: render_test_runner_header(&active_groups),
118            generated_header: true,
119        });
120
121        // Generate main.c.
122        files.push(GeneratedFile {
123            path: output_base.join("main.c"),
124            content: render_main_c(&active_groups),
125            generated_header: true,
126        });
127
128        let field_resolver = FieldResolver::new(
129            &e2e_config.fields,
130            &e2e_config.fields_optional,
131            &e2e_config.result_fields,
132            &e2e_config.fields_array,
133            &std::collections::HashSet::new(),
134        );
135
136        // Generate per-category test files.
137        // Each fixture may reference a named call config (fixture.call), so we pass
138        // e2e_config to render_test_file so it can resolve per-fixture call settings.
139        for (group, active) in &active_groups {
140            let filename = format!("test_{}.c", sanitize_filename(&group.category));
141            let content = render_test_file(
142                &group.category,
143                active,
144                &header,
145                &prefix,
146                result_var,
147                e2e_config,
148                lang,
149                &field_resolver,
150            );
151            files.push(GeneratedFile {
152                path: output_base.join(filename),
153                content,
154                generated_header: true,
155            });
156        }
157
158        Ok(files)
159    }
160
161    fn language_name(&self) -> &'static str {
162        "c"
163    }
164}
165
166/// Resolve per-call-config C-specific settings for a given call config and lang.
167struct ResolvedCallInfo {
168    function_name: String,
169    result_type_name: String,
170    options_type_name: String,
171    client_factory: Option<String>,
172    args: Vec<crate::config::ArgMapping>,
173    raw_c_result_type: Option<String>,
174    c_free_fn: Option<String>,
175    result_is_option: bool,
176}
177
178fn resolve_call_info(call: &CallConfig, lang: &str) -> ResolvedCallInfo {
179    let overrides = call.overrides.get(lang);
180    let function_name = overrides
181        .and_then(|o| o.function.as_ref())
182        .cloned()
183        .unwrap_or_else(|| call.function.clone());
184    // Fall back to the *base* (non-C-overridden) function name when no explicit
185    // result_type is set.  Using the C-overridden name (e.g. "htm_convert") would
186    // produce a doubled-prefix type like `HTMHtmConvert*`; the base name
187    // ("convert") yields the correct `HTMConvert*` shape.
188    let result_type_name = overrides
189        .and_then(|o| o.result_type.as_ref())
190        .cloned()
191        .unwrap_or_else(|| call.function.to_pascal_case());
192    let options_type_name = overrides
193        .and_then(|o| o.options_type.as_deref())
194        .unwrap_or("ConversionOptions")
195        .to_string();
196    let client_factory = overrides.and_then(|o| o.client_factory.as_ref()).cloned();
197    let raw_c_result_type = overrides.and_then(|o| o.raw_c_result_type.clone());
198    let c_free_fn = overrides.and_then(|o| o.c_free_fn.clone());
199    let result_is_option = overrides
200        .and_then(|o| if o.result_is_option { Some(true) } else { None })
201        .unwrap_or(call.result_is_option);
202    ResolvedCallInfo {
203        function_name,
204        result_type_name,
205        options_type_name,
206        client_factory,
207        args: call.args.clone(),
208        raw_c_result_type,
209        c_free_fn,
210        result_is_option,
211    }
212}
213
214/// Resolve call info for a fixture, with fallback to default call's client_factory.
215///
216/// Named call configs (e.g. `[e2e.calls.embed]`) may not repeat the `client_factory`
217/// setting. We fall back to the default `[e2e.call]` override's client_factory so that
218/// all methods on the same client use the same pattern.
219fn resolve_fixture_call_info(fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) -> ResolvedCallInfo {
220    let call = e2e_config.resolve_call(fixture.call.as_deref());
221    let mut info = resolve_call_info(call, lang);
222
223    // Fallback: if the named call has no client_factory override, inherit from the
224    // default call config so all calls use the same client pattern.
225    if info.client_factory.is_none() {
226        let default_overrides = e2e_config.call.overrides.get(lang);
227        if let Some(factory) = default_overrides.and_then(|o| o.client_factory.as_ref()) {
228            info.client_factory = Some(factory.clone());
229        }
230    }
231
232    info
233}
234
235fn render_makefile(categories: &[String], header_name: &str, ffi_crate_path: &str, lib_name: &str) -> String {
236    let mut out = String::new();
237    out.push_str(&hash::header(CommentStyle::Hash));
238    let _ = writeln!(out, "CC = gcc");
239    let _ = writeln!(out, "FFI_DIR = ffi");
240    let _ = writeln!(out);
241
242    // Rust's cdylib output normalizes hyphens to underscores in the filename
243    // (e.g. crate "html-to-markdown-ffi" → "libhtml_to_markdown_ffi.dylib").
244    // The -l linker flag must therefore use the underscore form, while the
245    // pkg-config package name retains the original form (as declared in the .pc file).
246    let link_lib_name = lib_name.replace('-', "_");
247
248    // 3-path fallback: ffi/ (download script) -> local repo build -> pkg-config.
249    let _ = writeln!(out, "ifneq ($(wildcard $(FFI_DIR)/include/{header_name}),)");
250    let _ = writeln!(out, "    CFLAGS = -Wall -Wextra -I. -I$(FFI_DIR)/include");
251    let _ = writeln!(
252        out,
253        "    LDFLAGS = -L$(FFI_DIR)/lib -l{link_lib_name} -Wl,-rpath,$(FFI_DIR)/lib"
254    );
255    let _ = writeln!(out, "else ifneq ($(wildcard {ffi_crate_path}/include/{header_name}),)");
256    let _ = writeln!(out, "    CFLAGS = -Wall -Wextra -I. -I{ffi_crate_path}/include");
257    let _ = writeln!(
258        out,
259        "    LDFLAGS = -L../../target/release -l{link_lib_name} -Wl,-rpath,../../target/release"
260    );
261    let _ = writeln!(out, "else");
262    let _ = writeln!(
263        out,
264        "    CFLAGS = -Wall -Wextra -I. $(shell pkg-config --cflags {lib_name} 2>/dev/null)"
265    );
266    let _ = writeln!(out, "    LDFLAGS = $(shell pkg-config --libs {lib_name} 2>/dev/null)");
267    let _ = writeln!(out, "endif");
268    let _ = writeln!(out);
269
270    let src_files: Vec<String> = categories.iter().map(|c| format!("test_{c}.c")).collect();
271    let srcs = src_files.join(" ");
272
273    let _ = writeln!(out, "SRCS = main.c {srcs}");
274    let _ = writeln!(out, "TARGET = run_tests");
275    let _ = writeln!(out);
276    let _ = writeln!(out, ".PHONY: all clean test");
277    let _ = writeln!(out);
278    let _ = writeln!(out, "all: $(TARGET)");
279    let _ = writeln!(out);
280    let _ = writeln!(out, "$(TARGET): $(SRCS)");
281    let _ = writeln!(out, "\t$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)");
282    let _ = writeln!(out);
283    let _ = writeln!(out, "test: $(TARGET)");
284    let _ = writeln!(out, "\t./$(TARGET)");
285    let _ = writeln!(out);
286    let _ = writeln!(out, "clean:");
287    let _ = writeln!(out, "\trm -f $(TARGET)");
288    out
289}
290
291fn render_download_script(github_repo: &str, version: &str, ffi_pkg_name: &str) -> String {
292    let mut out = String::new();
293    let _ = writeln!(out, "#!/usr/bin/env bash");
294    out.push_str(&hash::header(CommentStyle::Hash));
295    let _ = writeln!(out, "set -euo pipefail");
296    let _ = writeln!(out);
297    let _ = writeln!(out, "REPO_URL=\"{github_repo}\"");
298    let _ = writeln!(out, "VERSION=\"{version}\"");
299    let _ = writeln!(out, "FFI_PKG_NAME=\"{ffi_pkg_name}\"");
300    let _ = writeln!(out, "FFI_DIR=\"ffi\"");
301    let _ = writeln!(out);
302    let _ = writeln!(out, "# Detect OS and architecture.");
303    let _ = writeln!(out, "OS=\"$(uname -s | tr '[:upper:]' '[:lower:]')\"");
304    let _ = writeln!(out, "ARCH=\"$(uname -m)\"");
305    let _ = writeln!(out);
306    let _ = writeln!(out, "case \"$ARCH\" in");
307    let _ = writeln!(out, "x86_64 | amd64) ARCH=\"x86_64\" ;;");
308    let _ = writeln!(out, "arm64 | aarch64) ARCH=\"aarch64\" ;;");
309    let _ = writeln!(out, "*)");
310    let _ = writeln!(out, "  echo \"Unsupported architecture: $ARCH\" >&2");
311    let _ = writeln!(out, "  exit 1");
312    let _ = writeln!(out, "  ;;");
313    let _ = writeln!(out, "esac");
314    let _ = writeln!(out);
315    let _ = writeln!(out, "case \"$OS\" in");
316    let _ = writeln!(out, "linux) TRIPLE=\"${{ARCH}}-unknown-linux-gnu\" ;;");
317    let _ = writeln!(out, "darwin) TRIPLE=\"${{ARCH}}-apple-darwin\" ;;");
318    let _ = writeln!(out, "*)");
319    let _ = writeln!(out, "  echo \"Unsupported OS: $OS\" >&2");
320    let _ = writeln!(out, "  exit 1");
321    let _ = writeln!(out, "  ;;");
322    let _ = writeln!(out, "esac");
323    let _ = writeln!(out);
324    let _ = writeln!(out, "ARCHIVE=\"${{FFI_PKG_NAME}}-${{TRIPLE}}.tar.gz\"");
325    let _ = writeln!(
326        out,
327        "URL=\"${{REPO_URL}}/releases/download/v${{VERSION}}/${{ARCHIVE}}\""
328    );
329    let _ = writeln!(out);
330    let _ = writeln!(out, "echo \"Downloading ${{ARCHIVE}} from v${{VERSION}}...\"");
331    let _ = writeln!(out, "mkdir -p \"$FFI_DIR\"");
332    let _ = writeln!(out, "curl -fSL \"$URL\" | tar xz -C \"$FFI_DIR\"");
333    let _ = writeln!(out, "echo \"FFI library extracted to $FFI_DIR/\"");
334    out
335}
336
337fn render_test_runner_header(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
338    let mut out = String::new();
339    out.push_str(&hash::header(CommentStyle::Block));
340    let _ = writeln!(out, "#ifndef TEST_RUNNER_H");
341    let _ = writeln!(out, "#define TEST_RUNNER_H");
342    let _ = writeln!(out);
343    let _ = writeln!(out, "#include <string.h>");
344    let _ = writeln!(out, "#include <stdlib.h>");
345    let _ = writeln!(out);
346    // Trim helper for comparing strings that may have trailing whitespace/newlines.
347    let _ = writeln!(out, "/**");
348    let _ = writeln!(
349        out,
350        " * Compare a string against an expected value, trimming trailing whitespace."
351    );
352    let _ = writeln!(
353        out,
354        " * Returns 0 if the trimmed actual string equals the expected string."
355    );
356    let _ = writeln!(out, " */");
357    let _ = writeln!(
358        out,
359        "static inline int str_trim_eq(const char *actual, const char *expected) {{"
360    );
361    let _ = writeln!(
362        out,
363        "    if (actual == NULL || expected == NULL) return actual != expected;"
364    );
365    let _ = writeln!(out, "    size_t alen = strlen(actual);");
366    let _ = writeln!(
367        out,
368        "    while (alen > 0 && (actual[alen-1] == ' ' || actual[alen-1] == '\\n' || actual[alen-1] == '\\r' || actual[alen-1] == '\\t')) alen--;"
369    );
370    let _ = writeln!(out, "    size_t elen = strlen(expected);");
371    let _ = writeln!(out, "    if (alen != elen) return 1;");
372    let _ = writeln!(out, "    return memcmp(actual, expected, elen);");
373    let _ = writeln!(out, "}}");
374    let _ = writeln!(out);
375
376    let _ = writeln!(out, "/**");
377    let _ = writeln!(
378        out,
379        " * Extract a string value for a given key from a JSON object string."
380    );
381    let _ = writeln!(
382        out,
383        " * Returns a heap-allocated copy of the value, or NULL if not found."
384    );
385    let _ = writeln!(out, " * Caller must free() the returned string.");
386    let _ = writeln!(out, " */");
387    let _ = writeln!(
388        out,
389        "static inline char *alef_json_get_string(const char *json, const char *key) {{"
390    );
391    let _ = writeln!(out, "    if (json == NULL || key == NULL) return NULL;");
392    let _ = writeln!(out, "    /* Build search pattern: \"key\":  */");
393    let _ = writeln!(out, "    size_t key_len = strlen(key);");
394    let _ = writeln!(out, "    char *pattern = (char *)malloc(key_len + 5);");
395    let _ = writeln!(out, "    if (!pattern) return NULL;");
396    let _ = writeln!(out, "    pattern[0] = '\"';");
397    let _ = writeln!(out, "    memcpy(pattern + 1, key, key_len);");
398    let _ = writeln!(out, "    pattern[key_len + 1] = '\"';");
399    let _ = writeln!(out, "    pattern[key_len + 2] = ':';");
400    let _ = writeln!(out, "    pattern[key_len + 3] = '\\0';");
401    let _ = writeln!(out, "    const char *found = strstr(json, pattern);");
402    let _ = writeln!(out, "    free(pattern);");
403    let _ = writeln!(out, "    if (!found) return NULL;");
404    let _ = writeln!(out, "    found += key_len + 3; /* skip past \"key\": */");
405    let _ = writeln!(out, "    while (*found == ' ' || *found == '\\t') found++;");
406    let _ = writeln!(out, "    if (*found != '\"') return NULL; /* not a string value */");
407    let _ = writeln!(out, "    found++; /* skip opening quote */");
408    let _ = writeln!(out, "    const char *end = found;");
409    let _ = writeln!(out, "    while (*end && *end != '\"') {{");
410    let _ = writeln!(out, "        if (*end == '\\\\') {{ end++; if (*end) end++; }}");
411    let _ = writeln!(out, "        else end++;");
412    let _ = writeln!(out, "    }}");
413    let _ = writeln!(out, "    size_t val_len = (size_t)(end - found);");
414    let _ = writeln!(out, "    char *result_str = (char *)malloc(val_len + 1);");
415    let _ = writeln!(out, "    if (!result_str) return NULL;");
416    let _ = writeln!(out, "    memcpy(result_str, found, val_len);");
417    let _ = writeln!(out, "    result_str[val_len] = '\\0';");
418    let _ = writeln!(out, "    return result_str;");
419    let _ = writeln!(out, "}}");
420    let _ = writeln!(out);
421    let _ = writeln!(out, "/**");
422    let _ = writeln!(out, " * Count top-level elements in a JSON array string.");
423    let _ = writeln!(out, " * Returns 0 for empty arrays (\"[]\") or NULL input.");
424    let _ = writeln!(out, " */");
425    let _ = writeln!(out, "static inline int alef_json_array_count(const char *json) {{");
426    let _ = writeln!(out, "    if (json == NULL) return 0;");
427    let _ = writeln!(out, "    /* Skip leading whitespace */");
428    let _ = writeln!(
429        out,
430        "    while (*json == ' ' || *json == '\\t' || *json == '\\n') json++;"
431    );
432    let _ = writeln!(out, "    if (*json != '[') return 0;");
433    let _ = writeln!(out, "    json++;");
434    let _ = writeln!(out, "    /* Skip whitespace after '[' */");
435    let _ = writeln!(
436        out,
437        "    while (*json == ' ' || *json == '\\t' || *json == '\\n') json++;"
438    );
439    let _ = writeln!(out, "    if (*json == ']') return 0;");
440    let _ = writeln!(out, "    int count = 1;");
441    let _ = writeln!(out, "    int depth = 0;");
442    let _ = writeln!(out, "    int in_string = 0;");
443    let _ = writeln!(
444        out,
445        "    for (; *json && !(*json == ']' && depth == 0 && !in_string); json++) {{"
446    );
447    let _ = writeln!(out, "        if (*json == '\\\\' && in_string) {{ json++; continue; }}");
448    let _ = writeln!(
449        out,
450        "        if (*json == '\"') {{ in_string = !in_string; continue; }}"
451    );
452    let _ = writeln!(out, "        if (in_string) continue;");
453    let _ = writeln!(out, "        if (*json == '[' || *json == '{{') depth++;");
454    let _ = writeln!(out, "        else if (*json == ']' || *json == '}}') depth--;");
455    let _ = writeln!(out, "        else if (*json == ',' && depth == 0) count++;");
456    let _ = writeln!(out, "    }}");
457    let _ = writeln!(out, "    return count;");
458    let _ = writeln!(out, "}}");
459    let _ = writeln!(out);
460
461    for (group, fixtures) in active_groups {
462        let _ = writeln!(out, "/* Tests for category: {} */", group.category);
463        for fixture in fixtures {
464            let fn_name = sanitize_ident(&fixture.id);
465            let _ = writeln!(out, "void test_{fn_name}(void);");
466        }
467        let _ = writeln!(out);
468    }
469
470    let _ = writeln!(out, "#endif /* TEST_RUNNER_H */");
471    out
472}
473
474fn render_main_c(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
475    let mut out = String::new();
476    out.push_str(&hash::header(CommentStyle::Block));
477    let _ = writeln!(out, "#include <stdio.h>");
478    let _ = writeln!(out, "#include \"test_runner.h\"");
479    let _ = writeln!(out);
480    let _ = writeln!(out, "int main(void) {{");
481    let _ = writeln!(out, "    int passed = 0;");
482    let _ = writeln!(out);
483
484    for (group, fixtures) in active_groups {
485        let _ = writeln!(out, "    /* Category: {} */", group.category);
486        for fixture in fixtures {
487            let fn_name = sanitize_ident(&fixture.id);
488            let _ = writeln!(out, "    printf(\"  Running test_{fn_name}...\");");
489            let _ = writeln!(out, "    test_{fn_name}();");
490            let _ = writeln!(out, "    printf(\" PASSED\\n\");");
491            let _ = writeln!(out, "    passed++;");
492        }
493        let _ = writeln!(out);
494    }
495
496    let _ = writeln!(out, "    printf(\"\\nResults: %d passed, 0 failed\\n\", passed);");
497    let _ = writeln!(out, "    return 0;");
498    let _ = writeln!(out, "}}");
499    out
500}
501
502#[allow(clippy::too_many_arguments)]
503fn render_test_file(
504    category: &str,
505    fixtures: &[&Fixture],
506    header: &str,
507    prefix: &str,
508    result_var: &str,
509    e2e_config: &E2eConfig,
510    lang: &str,
511    field_resolver: &FieldResolver,
512) -> String {
513    let mut out = String::new();
514    out.push_str(&hash::header(CommentStyle::Block));
515    let _ = writeln!(out, "/* E2e tests for category: {category} */");
516    let _ = writeln!(out);
517    let _ = writeln!(out, "#include <assert.h>");
518    let _ = writeln!(out, "#include <string.h>");
519    let _ = writeln!(out, "#include <stdio.h>");
520    let _ = writeln!(out, "#include <stdlib.h>");
521    let _ = writeln!(out, "#include \"{header}\"");
522    let _ = writeln!(out, "#include \"test_runner.h\"");
523    let _ = writeln!(out);
524
525    for (i, fixture) in fixtures.iter().enumerate() {
526        // Visitor fixtures are filtered out before render_test_file is called.
527        // This guard is a safety net in case a fixture reaches here unexpectedly.
528        if fixture.visitor.is_some() {
529            panic!(
530                "C e2e generator: visitor pattern not supported for fixture: {}",
531                fixture.id
532            );
533        }
534
535        let call_info = resolve_fixture_call_info(fixture, e2e_config, lang);
536        render_test_function(
537            &mut out,
538            fixture,
539            prefix,
540            &call_info.function_name,
541            result_var,
542            &call_info.args,
543            field_resolver,
544            &e2e_config.fields_c_types,
545            &call_info.result_type_name,
546            &call_info.options_type_name,
547            call_info.client_factory.as_deref(),
548            call_info.raw_c_result_type.as_deref(),
549            call_info.c_free_fn.as_deref(),
550            call_info.result_is_option,
551        );
552        if i + 1 < fixtures.len() {
553            let _ = writeln!(out);
554        }
555    }
556
557    out
558}
559
560#[allow(clippy::too_many_arguments)]
561fn render_test_function(
562    out: &mut String,
563    fixture: &Fixture,
564    prefix: &str,
565    function_name: &str,
566    result_var: &str,
567    args: &[crate::config::ArgMapping],
568    field_resolver: &FieldResolver,
569    fields_c_types: &HashMap<String, String>,
570    result_type_name: &str,
571    options_type_name: &str,
572    client_factory: Option<&str>,
573    raw_c_result_type: Option<&str>,
574    c_free_fn: Option<&str>,
575    result_is_option: bool,
576) {
577    let fn_name = sanitize_ident(&fixture.id);
578    let description = &fixture.description;
579
580    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
581
582    let _ = writeln!(out, "void test_{fn_name}(void) {{");
583    let _ = writeln!(out, "    /* {description} */");
584
585    let prefix_upper = prefix.to_uppercase();
586
587    // Client pattern: used when client_factory is configured (e.g. liter-llm).
588    // Builds typed request handles from json_object args, creates a client via the
589    // factory function, calls {prefix}_default_client_{function_name}(client, req),
590    // then frees result, request handles, and client.
591    if let Some(factory) = client_factory {
592        let mut request_handle_vars: Vec<(String, String)> = Vec::new(); // (arg_name, var_name)
593
594        for arg in args {
595            if arg.arg_type == "json_object" {
596                // Prefer options_type from the C override when set, since the result
597                // type isn't always a clean strip-Response/append-Request transform
598                // (e.g. transcribe -> Create**Transcription**Request, not TranscriptionRequest).
599                // Fall back to deriving from result_type for backward-compat cases.
600                let request_type_pascal = if !options_type_name.is_empty() && options_type_name != "ConversionOptions" {
601                    options_type_name.to_string()
602                } else if let Some(stripped) = result_type_name.strip_suffix("Response") {
603                    format!("{}Request", stripped)
604                } else {
605                    format!("{result_type_name}Request")
606                };
607                let request_type_snake = request_type_pascal.to_snake_case();
608                let var_name = format!("{request_type_snake}_handle");
609
610                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
611                let json_val = if field.is_empty() || field == "input" {
612                    Some(&fixture.input)
613                } else {
614                    fixture.input.get(field)
615                };
616
617                if let Some(val) = json_val {
618                    if !val.is_null() {
619                        let normalized = super::normalize_json_keys_to_snake_case(val);
620                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
621                        let escaped = escape_c(&json_str);
622                        let _ = writeln!(
623                            out,
624                            "    {prefix_upper}{request_type_pascal}* {var_name} = \
625                             {prefix}_{request_type_snake}_from_json(\"{escaped}\");"
626                        );
627                        let _ = writeln!(out, "    assert({var_name} != NULL && \"failed to build request\");");
628                        request_handle_vars.push((arg.name.clone(), var_name));
629                    }
630                }
631            }
632        }
633
634        let _ = writeln!(
635            out,
636            "    {prefix_upper}DefaultClient* client = {prefix}_{factory}(\"test-key\", NULL, 0, 0, NULL);"
637        );
638        let _ = writeln!(out, "    assert(client != NULL && \"failed to create client\");");
639
640        let method_args = if request_handle_vars.is_empty() {
641            String::new()
642        } else {
643            let handles: Vec<&str> = request_handle_vars.iter().map(|(_, v)| v.as_str()).collect();
644            format!(", {}", handles.join(", "))
645        };
646
647        let call_fn = format!("{prefix}_default_client_{function_name}");
648
649        if expects_error {
650            let _ = writeln!(
651                out,
652                "    {prefix_upper}{result_type_name}* {result_var} = {call_fn}(client{method_args});"
653            );
654            for (_, var_name) in &request_handle_vars {
655                let req_snake = var_name.strip_suffix("_handle").unwrap_or(var_name);
656                let _ = writeln!(out, "    {prefix}_{req_snake}_free({var_name});");
657            }
658            let _ = writeln!(out, "    {prefix}_default_client_free(client);");
659            let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
660            let _ = writeln!(out, "}}");
661            return;
662        }
663
664        let _ = writeln!(
665            out,
666            "    {prefix_upper}{result_type_name}* {result_var} = {call_fn}(client{method_args});"
667        );
668        let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
669
670        let mut intermediate_handles: Vec<(String, String)> = Vec::new();
671        let mut accessed_fields: Vec<(String, String, bool)> = Vec::new();
672
673        for assertion in &fixture.assertions {
674            if let Some(f) = &assertion.field {
675                if !f.is_empty() && !accessed_fields.iter().any(|(k, _, _)| k == f) {
676                    let resolved = field_resolver.resolve(f);
677                    let local_var = f.replace(['.', '['], "_").replace(']', "");
678                    let has_map_access = resolved.contains('[');
679                    if resolved.contains('.') {
680                        emit_nested_accessor(
681                            out,
682                            prefix,
683                            resolved,
684                            &local_var,
685                            result_var,
686                            fields_c_types,
687                            &mut intermediate_handles,
688                            result_type_name,
689                        );
690                    } else {
691                        let result_type_snake = result_type_name.to_snake_case();
692                        let accessor_fn = format!("{prefix}_{result_type_snake}_{resolved}");
693                        let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({result_var});");
694                    }
695                    accessed_fields.push((f.clone(), local_var, has_map_access));
696                }
697            }
698        }
699
700        for assertion in &fixture.assertions {
701            render_assertion(out, assertion, result_var, prefix, field_resolver, &accessed_fields);
702        }
703
704        for (_f, local_var, from_json) in &accessed_fields {
705            if *from_json {
706                let _ = writeln!(out, "    free({local_var});");
707            } else {
708                let _ = writeln!(out, "    {prefix}_free_string({local_var});");
709            }
710        }
711        for (handle_var, snake_type) in intermediate_handles.iter().rev() {
712            if snake_type == "free_string" {
713                let _ = writeln!(out, "    {prefix}_free_string({handle_var});");
714            } else {
715                let _ = writeln!(out, "    {prefix}_{snake_type}_free({handle_var});");
716            }
717        }
718        let result_type_snake = result_type_name.to_snake_case();
719        let _ = writeln!(out, "    {prefix}_{result_type_snake}_free({result_var});");
720        for (_, var_name) in &request_handle_vars {
721            let req_snake = var_name.strip_suffix("_handle").unwrap_or(var_name);
722            let _ = writeln!(out, "    {prefix}_{req_snake}_free({var_name});");
723        }
724        let _ = writeln!(out, "    {prefix}_default_client_free(client);");
725        let _ = writeln!(out, "}}");
726        return;
727    }
728
729    // Raw C result type path: functions returning a primitive C type (char*, int32_t,
730    // uintptr_t) rather than an opaque handle pointer.
731    if let Some(raw_type) = raw_c_result_type {
732        // Build argument string. Void-arg functions pass nothing.
733        let args_str = if args.is_empty() {
734            String::new()
735        } else {
736            let parts: Vec<String> = args
737                .iter()
738                .filter_map(|arg| {
739                    let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
740                    let val = fixture.input.get(field);
741                    match val {
742                        None if arg.optional => Some("NULL".to_string()),
743                        None => None,
744                        Some(v) if v.is_null() && arg.optional => Some("NULL".to_string()),
745                        Some(v) => Some(json_to_c(v)),
746                    }
747                })
748                .collect();
749            parts.join(", ")
750        };
751
752        // Declare result variable.
753        let _ = writeln!(out, "    {raw_type} {result_var} = {function_name}({args_str});");
754
755        // not_error assertion.
756        let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
757        if has_not_error {
758            match raw_type {
759                "char*" if !result_is_option => {
760                    let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
761                }
762                "int32_t" => {
763                    let _ = writeln!(out, "    assert({result_var} >= 0 && \"expected call to succeed\");");
764                }
765                "uintptr_t" => {
766                    let _ = writeln!(
767                        out,
768                        "    assert({prefix}_last_error_code() == 0 && \"expected call to succeed\");"
769                    );
770                }
771                _ => {}
772            }
773        }
774
775        // Other assertions.
776        for assertion in &fixture.assertions {
777            match assertion.assertion_type.as_str() {
778                "not_error" | "error" => {} // handled above / not applicable
779                "not_empty" => {
780                    let _ = writeln!(
781                        out,
782                        "    assert({result_var} != NULL && strlen({result_var}) > 0 && \"expected non-empty value\");"
783                    );
784                }
785                "is_empty" => {
786                    if result_is_option && raw_type == "char*" {
787                        let _ = writeln!(
788                            out,
789                            "    assert({result_var} == NULL && \"expected empty/null value\");"
790                        );
791                    } else {
792                        let _ = writeln!(
793                            out,
794                            "    assert(strlen({result_var}) == 0 && \"expected empty value\");"
795                        );
796                    }
797                }
798                "count_min" => {
799                    if let Some(val) = &assertion.value {
800                        if let Some(n) = val.as_u64() {
801                            match raw_type {
802                                "char*" => {
803                                    let _ = writeln!(out, "    {{");
804                                    let _ = writeln!(
805                                        out,
806                                        "        assert({result_var} != NULL && \"expected non-null JSON array\");"
807                                    );
808                                    let _ =
809                                        writeln!(out, "        int elem_count = alef_json_array_count({result_var});");
810                                    let _ = writeln!(
811                                        out,
812                                        "        assert(elem_count >= {n} && \"expected at least {n} elements\");"
813                                    );
814                                    let _ = writeln!(out, "    }}");
815                                }
816                                _ => {
817                                    let _ = writeln!(
818                                        out,
819                                        "    assert((size_t){result_var} >= {n} && \"expected at least {n} elements\");"
820                                    );
821                                }
822                            }
823                        }
824                    }
825                }
826                "greater_than_or_equal" => {
827                    if let Some(val) = &assertion.value {
828                        let c_val = json_to_c(val);
829                        let _ = writeln!(
830                            out,
831                            "    assert({result_var} >= {c_val} && \"expected greater than or equal\");"
832                        );
833                    }
834                }
835                "contains" => {
836                    if let Some(val) = &assertion.value {
837                        let c_val = json_to_c(val);
838                        let _ = writeln!(
839                            out,
840                            "    assert(strstr({result_var}, {c_val}) != NULL && \"expected to contain substring\");"
841                        );
842                    }
843                }
844                "contains_all" => {
845                    if let Some(values) = &assertion.values {
846                        for val in values {
847                            let c_val = json_to_c(val);
848                            let _ = writeln!(
849                                out,
850                                "    assert(strstr({result_var}, {c_val}) != NULL && \"expected to contain substring\");"
851                            );
852                        }
853                    }
854                }
855                "equals" => {
856                    if let Some(val) = &assertion.value {
857                        let c_val = json_to_c(val);
858                        if val.is_string() {
859                            let _ = writeln!(
860                                out,
861                                "    assert({result_var} != NULL && str_trim_eq({result_var}, {c_val}) == 0 && \"equals assertion failed\");"
862                            );
863                        } else {
864                            let _ = writeln!(
865                                out,
866                                "    assert({result_var} == {c_val} && \"equals assertion failed\");"
867                            );
868                        }
869                    }
870                }
871                "not_contains" => {
872                    if let Some(val) = &assertion.value {
873                        let c_val = json_to_c(val);
874                        let _ = writeln!(
875                            out,
876                            "    assert(strstr({result_var}, {c_val}) == NULL && \"expected NOT to contain substring\");"
877                        );
878                    }
879                }
880                "starts_with" => {
881                    if let Some(val) = &assertion.value {
882                        let c_val = json_to_c(val);
883                        let _ = writeln!(
884                            out,
885                            "    assert(strncmp({result_var}, {c_val}, strlen({c_val})) == 0 && \"expected to start with\");"
886                        );
887                    }
888                }
889                "is_true" => {
890                    let _ = writeln!(out, "    assert({result_var});");
891                }
892                "is_false" => {
893                    let _ = writeln!(out, "    assert(!{result_var});");
894                }
895                other => {
896                    panic!("C e2e raw-result generator: unsupported assertion type: {other}");
897                }
898            }
899        }
900
901        // Free char* results.
902        if raw_type == "char*" {
903            let free_fn = c_free_fn
904                .map(|s| s.to_string())
905                .unwrap_or_else(|| format!("{prefix}_free_string"));
906            if result_is_option {
907                let _ = writeln!(out, "    if ({result_var} != NULL) {{ {free_fn}({result_var}); }}");
908            } else {
909                let _ = writeln!(out, "    {free_fn}({result_var});");
910            }
911        }
912
913        let _ = writeln!(out, "}}");
914        return;
915    }
916
917    // Legacy (non-client) path: call the function directly.
918    // Used for libraries like html-to-markdown that expose standalone FFI functions.
919
920    // Use the function name directly — the override already includes the prefix
921    // (e.g. "htm_convert"), so we must NOT prepend it again.
922    let prefixed_fn = function_name.to_string();
923
924    // For json_object args, emit a from_json call to construct the options handle.
925    let mut has_options_handle = false;
926    for arg in args {
927        if arg.arg_type == "json_object" {
928            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
929            if let Some(val) = fixture.input.get(field) {
930                if !val.is_null() {
931                    // Fixture keys are camelCase; the FFI htm_conversion_options_from_json
932                    // deserializes into the Rust ConversionOptions type which uses default
933                    // serde (snake_case). Normalize keys before serializing.
934                    let normalized = super::normalize_json_keys_to_snake_case(val);
935                    let json_str = serde_json::to_string(&normalized).unwrap_or_default();
936                    let escaped = escape_c(&json_str);
937                    let upper = prefix.to_uppercase();
938                    let options_type_pascal = options_type_name;
939                    let options_type_snake = options_type_name.to_snake_case();
940                    let _ = writeln!(
941                        out,
942                        "    {upper}{options_type_pascal}* options_handle = {prefix}_{options_type_snake}_from_json(\"{escaped}\");"
943                    );
944                    has_options_handle = true;
945                }
946            }
947        }
948    }
949
950    let args_str = build_args_string_c(&fixture.input, args, has_options_handle);
951
952    if expects_error {
953        let _ = writeln!(
954            out,
955            "    {prefix_upper}{result_type_name}* {result_var} = {prefixed_fn}({args_str});"
956        );
957        if has_options_handle {
958            let options_type_snake = options_type_name.to_snake_case();
959            let _ = writeln!(out, "    {prefix}_{options_type_snake}_free(options_handle);");
960        }
961        let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
962        let _ = writeln!(out, "}}");
963        return;
964    }
965
966    // The FFI returns an opaque handle; extract the content string from it.
967    let _ = writeln!(
968        out,
969        "    {prefix_upper}{result_type_name}* {result_var} = {prefixed_fn}({args_str});"
970    );
971    let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
972
973    // Collect fields accessed by assertions so we can emit accessor calls.
974    // C FFI uses the opaque handle pattern: {prefix}_conversion_result_{field}(handle).
975    // For nested paths we generate chained FFI accessor calls using the type
976    // chain from `fields_c_types`.
977    // Each entry: (fixture_field, local_var, from_json_extract).
978    // `from_json_extract` is true when the variable was extracted from a JSON
979    // map via alef_json_get_string and needs free() instead of {prefix}_free_string().
980    let mut accessed_fields: Vec<(String, String, bool)> = Vec::new();
981    // Track intermediate handles emitted so we can free them and avoid duplicates.
982    // Each entry: (handle_var_name, snake_type_name) — freed in reverse order.
983    let mut intermediate_handles: Vec<(String, String)> = Vec::new();
984
985    for assertion in &fixture.assertions {
986        if let Some(f) = &assertion.field {
987            if !f.is_empty() && !accessed_fields.iter().any(|(k, _, _)| k == f) {
988                let resolved = field_resolver.resolve(f);
989                let local_var = f.replace(['.', '['], "_").replace(']', "");
990                let has_map_access = resolved.contains('[');
991
992                if resolved.contains('.') {
993                    emit_nested_accessor(
994                        out,
995                        prefix,
996                        resolved,
997                        &local_var,
998                        result_var,
999                        fields_c_types,
1000                        &mut intermediate_handles,
1001                        result_type_name,
1002                    );
1003                } else {
1004                    let result_type_snake = result_type_name.to_snake_case();
1005                    let accessor_fn = format!("{prefix}_{result_type_snake}_{resolved}");
1006                    let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({result_var});");
1007                }
1008                accessed_fields.push((f.clone(), local_var.clone(), has_map_access));
1009            }
1010        }
1011    }
1012
1013    for assertion in &fixture.assertions {
1014        render_assertion(out, assertion, result_var, prefix, field_resolver, &accessed_fields);
1015    }
1016
1017    // Free extracted leaf strings.
1018    for (_f, local_var, from_json) in &accessed_fields {
1019        if *from_json {
1020            let _ = writeln!(out, "    free({local_var});");
1021        } else {
1022            let _ = writeln!(out, "    {prefix}_free_string({local_var});");
1023        }
1024    }
1025    // Free intermediate handles in reverse order.
1026    for (handle_var, snake_type) in intermediate_handles.iter().rev() {
1027        if snake_type == "free_string" {
1028            // free_string handles are freed with the free_string function directly.
1029            let _ = writeln!(out, "    {prefix}_free_string({handle_var});");
1030        } else {
1031            let _ = writeln!(out, "    {prefix}_{snake_type}_free({handle_var});");
1032        }
1033    }
1034    if has_options_handle {
1035        let options_type_snake = options_type_name.to_snake_case();
1036        let _ = writeln!(out, "    {prefix}_{options_type_snake}_free(options_handle);");
1037    }
1038    let result_type_snake = result_type_name.to_snake_case();
1039    let _ = writeln!(out, "    {prefix}_{result_type_snake}_free({result_var});");
1040    let _ = writeln!(out, "}}");
1041}
1042
1043/// Emit chained FFI accessor calls for a nested resolved field path.
1044///
1045/// For a path like `metadata.document.title`, this generates:
1046/// ```c
1047/// HTMHtmlMetadata* metadata_handle = htm_conversion_result_metadata(result);
1048/// assert(metadata_handle != NULL);
1049/// HTMDocumentMetadata* doc_handle = htm_html_metadata_document(metadata_handle);
1050/// assert(doc_handle != NULL);
1051/// char* metadata_title = htm_document_metadata_title(doc_handle);
1052/// ```
1053///
1054/// The type chain is looked up from `fields_c_types` which maps
1055/// `"{parent_snake_type}.{field}"` -> `"PascalCaseType"`.
1056#[allow(clippy::too_many_arguments)]
1057fn emit_nested_accessor(
1058    out: &mut String,
1059    prefix: &str,
1060    resolved: &str,
1061    local_var: &str,
1062    result_var: &str,
1063    fields_c_types: &HashMap<String, String>,
1064    intermediate_handles: &mut Vec<(String, String)>,
1065    result_type_name: &str,
1066) {
1067    let segments: Vec<&str> = resolved.split('.').collect();
1068    let prefix_upper = prefix.to_uppercase();
1069
1070    // Walk the path, starting from the root result type.
1071    let mut current_snake_type = result_type_name.to_snake_case();
1072    let mut current_handle = result_var.to_string();
1073
1074    for (i, segment) in segments.iter().enumerate() {
1075        let is_leaf = i + 1 == segments.len();
1076
1077        // Check for map access: "field[key]"
1078        if let Some(bracket_pos) = segment.find('[') {
1079            let field_name = &segment[..bracket_pos];
1080            let key = segment[bracket_pos + 1..].trim_end_matches(']');
1081            let field_snake = field_name.to_snake_case();
1082            let accessor_fn = format!("{prefix}_{current_snake_type}_{field_snake}");
1083
1084            // The map accessor returns a char* (JSON object string).
1085            // Use alef_json_get_string to extract the key value.
1086            let json_var = format!("{field_snake}_json");
1087            if !intermediate_handles.iter().any(|(h, _)| h == &json_var) {
1088                let _ = writeln!(out, "    char* {json_var} = {accessor_fn}({current_handle});");
1089                let _ = writeln!(out, "    assert({json_var} != NULL);");
1090                // Track for freeing — use prefix_free_string since it's a char*.
1091                intermediate_handles.push((json_var.clone(), "free_string".to_string()));
1092            }
1093            // Extract the key from the JSON map.
1094            let _ = writeln!(
1095                out,
1096                "    char* {local_var} = alef_json_get_string({json_var}, \"{key}\");"
1097            );
1098            return; // Map access is always the leaf.
1099        }
1100
1101        let seg_snake = segment.to_snake_case();
1102        let accessor_fn = format!("{prefix}_{current_snake_type}_{seg_snake}");
1103
1104        if is_leaf {
1105            // Leaf field returns char* — assign to the local variable.
1106            let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({current_handle});");
1107        } else {
1108            // Intermediate field returns an opaque handle.
1109            let lookup_key = format!("{current_snake_type}.{seg_snake}");
1110            let return_type_pascal = match fields_c_types.get(&lookup_key) {
1111                Some(t) => t.clone(),
1112                None => {
1113                    // Fallback: derive PascalCase from the segment name itself.
1114                    segment.to_pascal_case()
1115                }
1116            };
1117            let return_snake = return_type_pascal.to_snake_case();
1118            let handle_var = format!("{seg_snake}_handle");
1119
1120            // Only emit the handle if we haven't already (multiple fields may
1121            // share the same intermediate path prefix).
1122            if !intermediate_handles.iter().any(|(h, _)| h == &handle_var) {
1123                let _ = writeln!(
1124                    out,
1125                    "    {prefix_upper}{return_type_pascal}* {handle_var} = \
1126                     {accessor_fn}({current_handle});"
1127                );
1128                let _ = writeln!(out, "    assert({handle_var} != NULL);");
1129                intermediate_handles.push((handle_var.clone(), return_snake.clone()));
1130            }
1131
1132            current_snake_type = return_snake;
1133            current_handle = handle_var;
1134        }
1135    }
1136}
1137
1138/// Build the C argument string for the function call.
1139/// When `has_options_handle` is true, json_object args are replaced with
1140/// the `options_handle` pointer (which was constructed via `from_json`).
1141fn build_args_string_c(
1142    input: &serde_json::Value,
1143    args: &[crate::config::ArgMapping],
1144    has_options_handle: bool,
1145) -> String {
1146    if args.is_empty() {
1147        return json_to_c(input);
1148    }
1149
1150    let parts: Vec<String> = args
1151        .iter()
1152        .filter_map(|arg| {
1153            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1154            let val = input.get(field);
1155            match val {
1156                // Field missing entirely and optional → pass NULL.
1157                None if arg.optional => Some("NULL".to_string()),
1158                // Field missing and required → skip (caller error, but don't crash).
1159                None => None,
1160                // Explicit null on optional arg → pass NULL.
1161                Some(v) if v.is_null() && arg.optional => Some("NULL".to_string()),
1162                Some(v) => {
1163                    // For json_object args, use the options_handle pointer
1164                    // instead of the raw JSON string.
1165                    if arg.arg_type == "json_object" && has_options_handle && !v.is_null() {
1166                        Some("options_handle".to_string())
1167                    } else {
1168                        Some(json_to_c(v))
1169                    }
1170                }
1171            }
1172        })
1173        .collect();
1174
1175    parts.join(", ")
1176}
1177
1178fn render_assertion(
1179    out: &mut String,
1180    assertion: &Assertion,
1181    result_var: &str,
1182    ffi_prefix: &str,
1183    _field_resolver: &FieldResolver,
1184    accessed_fields: &[(String, String, bool)],
1185) {
1186    // Skip assertions on fields that don't exist on the result type.
1187    if let Some(f) = &assertion.field {
1188        if !f.is_empty() && !_field_resolver.is_valid_for_result(f) {
1189            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1190            return;
1191        }
1192    }
1193
1194    let field_expr = match &assertion.field {
1195        Some(f) if !f.is_empty() => {
1196            // Use the local variable extracted from the opaque handle.
1197            accessed_fields
1198                .iter()
1199                .find(|(k, _, _)| k == f)
1200                .map(|(_, local, _)| local.clone())
1201                .unwrap_or_else(|| result_var.to_string())
1202        }
1203        _ => result_var.to_string(),
1204    };
1205
1206    match assertion.assertion_type.as_str() {
1207        "equals" => {
1208            if let Some(expected) = &assertion.value {
1209                let c_val = json_to_c(expected);
1210                if expected.is_string() {
1211                    // Use str_trim_eq for string comparisons to handle trailing whitespace.
1212                    let _ = writeln!(
1213                        out,
1214                        "    assert(str_trim_eq({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
1215                    );
1216                } else {
1217                    let _ = writeln!(
1218                        out,
1219                        "    assert(strcmp({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
1220                    );
1221                }
1222            }
1223        }
1224        "contains" => {
1225            if let Some(expected) = &assertion.value {
1226                let c_val = json_to_c(expected);
1227                let _ = writeln!(
1228                    out,
1229                    "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
1230                );
1231            }
1232        }
1233        "contains_all" => {
1234            if let Some(values) = &assertion.values {
1235                for val in values {
1236                    let c_val = json_to_c(val);
1237                    let _ = writeln!(
1238                        out,
1239                        "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
1240                    );
1241                }
1242            }
1243        }
1244        "not_contains" => {
1245            if let Some(expected) = &assertion.value {
1246                let c_val = json_to_c(expected);
1247                let _ = writeln!(
1248                    out,
1249                    "    assert(strstr({field_expr}, {c_val}) == NULL && \"expected NOT to contain substring\");"
1250                );
1251            }
1252        }
1253        "not_empty" => {
1254            let _ = writeln!(
1255                out,
1256                "    assert({field_expr} != NULL && strlen({field_expr}) > 0 && \"expected non-empty value\");"
1257            );
1258        }
1259        "is_empty" => {
1260            let _ = writeln!(
1261                out,
1262                "    assert(strlen({field_expr}) == 0 && \"expected empty value\");"
1263            );
1264        }
1265        "contains_any" => {
1266            if let Some(values) = &assertion.values {
1267                let _ = writeln!(out, "    {{");
1268                let _ = writeln!(out, "        int found = 0;");
1269                for val in values {
1270                    let c_val = json_to_c(val);
1271                    let _ = writeln!(
1272                        out,
1273                        "        if (strstr({field_expr}, {c_val}) != NULL) {{ found = 1; }}"
1274                    );
1275                }
1276                let _ = writeln!(
1277                    out,
1278                    "        assert(found && \"expected to contain at least one of the specified values\");"
1279                );
1280                let _ = writeln!(out, "    }}");
1281            }
1282        }
1283        "greater_than" => {
1284            if let Some(val) = &assertion.value {
1285                let c_val = json_to_c(val);
1286                let _ = writeln!(out, "    assert({field_expr} > {c_val} && \"expected greater than\");");
1287            }
1288        }
1289        "less_than" => {
1290            if let Some(val) = &assertion.value {
1291                let c_val = json_to_c(val);
1292                let _ = writeln!(out, "    assert({field_expr} < {c_val} && \"expected less than\");");
1293            }
1294        }
1295        "greater_than_or_equal" => {
1296            if let Some(val) = &assertion.value {
1297                let c_val = json_to_c(val);
1298                let _ = writeln!(
1299                    out,
1300                    "    assert({field_expr} >= {c_val} && \"expected greater than or equal\");"
1301                );
1302            }
1303        }
1304        "less_than_or_equal" => {
1305            if let Some(val) = &assertion.value {
1306                let c_val = json_to_c(val);
1307                let _ = writeln!(
1308                    out,
1309                    "    assert({field_expr} <= {c_val} && \"expected less than or equal\");"
1310                );
1311            }
1312        }
1313        "starts_with" => {
1314            if let Some(expected) = &assertion.value {
1315                let c_val = json_to_c(expected);
1316                let _ = writeln!(
1317                    out,
1318                    "    assert(strncmp({field_expr}, {c_val}, strlen({c_val})) == 0 && \"expected to start with\");"
1319                );
1320            }
1321        }
1322        "ends_with" => {
1323            if let Some(expected) = &assertion.value {
1324                let c_val = json_to_c(expected);
1325                let _ = writeln!(out, "    assert(strlen({field_expr}) >= strlen({c_val}) && ");
1326                let _ = writeln!(
1327                    out,
1328                    "           strcmp({field_expr} + strlen({field_expr}) - strlen({c_val}), {c_val}) == 0 && \"expected to end with\");"
1329                );
1330            }
1331        }
1332        "min_length" => {
1333            if let Some(val) = &assertion.value {
1334                if let Some(n) = val.as_u64() {
1335                    let _ = writeln!(
1336                        out,
1337                        "    assert(strlen({field_expr}) >= {n} && \"expected minimum length\");"
1338                    );
1339                }
1340            }
1341        }
1342        "max_length" => {
1343            if let Some(val) = &assertion.value {
1344                if let Some(n) = val.as_u64() {
1345                    let _ = writeln!(
1346                        out,
1347                        "    assert(strlen({field_expr}) <= {n} && \"expected maximum length\");"
1348                    );
1349                }
1350            }
1351        }
1352        "count_min" => {
1353            if let Some(val) = &assertion.value {
1354                if let Some(n) = val.as_u64() {
1355                    let _ = writeln!(out, "    {{");
1356                    let _ = writeln!(out, "        /* count_min: count top-level JSON array elements */");
1357                    let _ = writeln!(
1358                        out,
1359                        "        assert({field_expr} != NULL && \"expected non-null collection JSON\");"
1360                    );
1361                    let _ = writeln!(out, "        int elem_count = alef_json_array_count({field_expr});");
1362                    let _ = writeln!(
1363                        out,
1364                        "        assert(elem_count >= {n} && \"expected at least {n} elements\");"
1365                    );
1366                    let _ = writeln!(out, "    }}");
1367                }
1368            }
1369        }
1370        "count_equals" => {
1371            if let Some(val) = &assertion.value {
1372                if let Some(n) = val.as_u64() {
1373                    let _ = writeln!(out, "    {{");
1374                    let _ = writeln!(out, "        /* count_equals: count elements in array */");
1375                    let _ = writeln!(
1376                        out,
1377                        "        assert({field_expr} != NULL && \"expected non-null collection JSON\");"
1378                    );
1379                    let _ = writeln!(out, "        int elem_count = alef_json_array_count({field_expr});");
1380                    let _ = writeln!(out, "        assert(elem_count == {n} && \"expected {n} elements\");");
1381                    let _ = writeln!(out, "    }}");
1382                }
1383            }
1384        }
1385        "is_true" => {
1386            let _ = writeln!(out, "    assert({field_expr});");
1387        }
1388        "is_false" => {
1389            let _ = writeln!(out, "    assert(!{field_expr});");
1390        }
1391        "method_result" => {
1392            if let Some(method_name) = &assertion.method {
1393                render_method_result_assertion(
1394                    out,
1395                    result_var,
1396                    ffi_prefix,
1397                    method_name,
1398                    assertion.args.as_ref(),
1399                    assertion.return_type.as_deref(),
1400                    assertion.check.as_deref().unwrap_or("is_true"),
1401                    assertion.value.as_ref(),
1402                );
1403            } else {
1404                panic!("C e2e generator: method_result assertion missing 'method' field");
1405            }
1406        }
1407        "matches_regex" => {
1408            if let Some(expected) = &assertion.value {
1409                let c_val = json_to_c(expected);
1410                let _ = writeln!(out, "    {{");
1411                let _ = writeln!(out, "        regex_t _re;");
1412                let _ = writeln!(
1413                    out,
1414                    "        assert(regcomp(&_re, {c_val}, REG_EXTENDED) == 0 && \"regex compile failed\");"
1415                );
1416                let _ = writeln!(
1417                    out,
1418                    "        assert(regexec(&_re, {field_expr}, 0, NULL, 0) == 0 && \"expected value to match regex\");"
1419                );
1420                let _ = writeln!(out, "        regfree(&_re);");
1421                let _ = writeln!(out, "    }}");
1422            }
1423        }
1424        "not_error" => {
1425            // Already handled — the NULL check above covers this.
1426        }
1427        "error" => {
1428            // Handled at the test function level.
1429        }
1430        other => {
1431            panic!("C e2e generator: unsupported assertion type: {other}");
1432        }
1433    }
1434}
1435
1436/// Render a `method_result` assertion in C.
1437///
1438/// Dispatches generically using `{ffi_prefix}_{method_name}` for the FFI call.
1439/// The `return_type` fixture field controls how the return value is handled:
1440/// - `"string"` — the method returns a heap-allocated `char*`; the generator
1441///   emits a scoped block that asserts, then calls `free()`.
1442/// - absent/other — treated as a primitive integer (or pointer-as-bool); the
1443///   assertion is emitted inline without any heap management.
1444#[allow(clippy::too_many_arguments)]
1445fn render_method_result_assertion(
1446    out: &mut String,
1447    result_var: &str,
1448    ffi_prefix: &str,
1449    method_name: &str,
1450    args: Option<&serde_json::Value>,
1451    return_type: Option<&str>,
1452    check: &str,
1453    value: Option<&serde_json::Value>,
1454) {
1455    let call_expr = build_c_method_call(result_var, ffi_prefix, method_name, args);
1456
1457    if return_type == Some("string") {
1458        // Heap-allocated char* return: emit a scoped block, assert, then free.
1459        let _ = writeln!(out, "    {{");
1460        let _ = writeln!(out, "        char* _method_result = {call_expr};");
1461        if check == "is_error" {
1462            let _ = writeln!(
1463                out,
1464                "        assert(_method_result == NULL && \"expected method to return error\");"
1465            );
1466            let _ = writeln!(out, "    }}");
1467            return;
1468        }
1469        let _ = writeln!(
1470            out,
1471            "        assert(_method_result != NULL && \"method_result returned NULL\");"
1472        );
1473        match check {
1474            "contains" => {
1475                if let Some(val) = value {
1476                    let c_val = json_to_c(val);
1477                    let _ = writeln!(
1478                        out,
1479                        "        assert(strstr(_method_result, {c_val}) != NULL && \"method_result contains assertion failed\");"
1480                    );
1481                }
1482            }
1483            "equals" => {
1484                if let Some(val) = value {
1485                    let c_val = json_to_c(val);
1486                    let _ = writeln!(
1487                        out,
1488                        "        assert(str_trim_eq(_method_result, {c_val}) == 0 && \"method_result equals assertion failed\");"
1489                    );
1490                }
1491            }
1492            "is_true" => {
1493                let _ = writeln!(
1494                    out,
1495                    "        assert(_method_result != NULL && strlen(_method_result) > 0 && \"method_result is_true assertion failed\");"
1496                );
1497            }
1498            "count_min" => {
1499                if let Some(val) = value {
1500                    let n = val.as_u64().unwrap_or(0);
1501                    let _ = writeln!(out, "        int _elem_count = alef_json_array_count(_method_result);");
1502                    let _ = writeln!(
1503                        out,
1504                        "        assert(_elem_count >= {n} && \"method_result count_min assertion failed\");"
1505                    );
1506                }
1507            }
1508            other_check => {
1509                panic!("C e2e generator: unsupported method_result check type for string return: {other_check}");
1510            }
1511        }
1512        let _ = writeln!(out, "        free(_method_result);");
1513        let _ = writeln!(out, "    }}");
1514        return;
1515    }
1516
1517    // Primitive (integer / pointer-as-bool) return: inline assert, no heap management.
1518    match check {
1519        "equals" => {
1520            if let Some(val) = value {
1521                let c_val = json_to_c(val);
1522                let _ = writeln!(
1523                    out,
1524                    "    assert({call_expr} == {c_val} && \"method_result equals assertion failed\");"
1525                );
1526            }
1527        }
1528        "is_true" => {
1529            let _ = writeln!(
1530                out,
1531                "    assert({call_expr} && \"method_result is_true assertion failed\");"
1532            );
1533        }
1534        "is_false" => {
1535            let _ = writeln!(
1536                out,
1537                "    assert(!{call_expr} && \"method_result is_false assertion failed\");"
1538            );
1539        }
1540        "greater_than_or_equal" => {
1541            if let Some(val) = value {
1542                let n = val.as_u64().unwrap_or(0);
1543                let _ = writeln!(
1544                    out,
1545                    "    assert({call_expr} >= {n} && \"method_result >= {n} assertion failed\");"
1546                );
1547            }
1548        }
1549        "count_min" => {
1550            if let Some(val) = value {
1551                let n = val.as_u64().unwrap_or(0);
1552                let _ = writeln!(
1553                    out,
1554                    "    assert({call_expr} >= {n} && \"method_result count_min assertion failed\");"
1555                );
1556            }
1557        }
1558        other_check => {
1559            panic!("C e2e generator: unsupported method_result check type: {other_check}");
1560        }
1561    }
1562}
1563
1564/// Build a C call expression for a `method_result` assertion.
1565///
1566/// Uses generic dispatch: `{ffi_prefix}_{method_name}(result_var, args...)`.
1567/// Args from the fixture JSON object are emitted as positional C arguments in
1568/// insertion order, using best-effort type conversion (strings → C string literals,
1569/// numbers and booleans → verbatim literals).
1570fn build_c_method_call(
1571    result_var: &str,
1572    ffi_prefix: &str,
1573    method_name: &str,
1574    args: Option<&serde_json::Value>,
1575) -> String {
1576    let extra_args = if let Some(args_val) = args {
1577        args_val
1578            .as_object()
1579            .map(|obj| {
1580                obj.values()
1581                    .map(|v| match v {
1582                        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
1583                        serde_json::Value::Bool(true) => "1".to_string(),
1584                        serde_json::Value::Bool(false) => "0".to_string(),
1585                        serde_json::Value::Number(n) => n.to_string(),
1586                        serde_json::Value::Null => "NULL".to_string(),
1587                        other => format!("\"{}\"", escape_c(&other.to_string())),
1588                    })
1589                    .collect::<Vec<_>>()
1590                    .join(", ")
1591            })
1592            .unwrap_or_default()
1593    } else {
1594        String::new()
1595    };
1596
1597    if extra_args.is_empty() {
1598        format!("{ffi_prefix}_{method_name}({result_var})")
1599    } else {
1600        format!("{ffi_prefix}_{method_name}({result_var}, {extra_args})")
1601    }
1602}
1603
1604/// Convert a `serde_json::Value` to a C literal string.
1605fn json_to_c(value: &serde_json::Value) -> String {
1606    match value {
1607        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
1608        serde_json::Value::Bool(true) => "1".to_string(),
1609        serde_json::Value::Bool(false) => "0".to_string(),
1610        serde_json::Value::Number(n) => n.to_string(),
1611        serde_json::Value::Null => "NULL".to_string(),
1612        other => format!("\"{}\"", escape_c(&other.to_string())),
1613    }
1614}