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::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 default call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let result_var = &call.result_var;
41        let prefix = overrides
42            .and_then(|o| o.prefix.as_ref())
43            .cloned()
44            .or_else(|| alef_config.ffi.as_ref().and_then(|ffi| ffi.prefix.as_ref()).cloned())
45            .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        // Default to `../../crates/{name}-ffi` derived from the crate name so that
74        // projects like `liter-llm` resolve to `../../crates/liter-llm-ffi/include/`
75        // rather than the generic (incorrect) `../../crates/ffi`.
76        let ffi_crate_path = c_pkg
77            .as_ref()
78            .and_then(|p| p.path.as_ref())
79            .cloned()
80            .unwrap_or_else(|| format!("../../crates/{}-ffi", alef_config.crate_config.name));
81
82        // Generate Makefile.
83        let category_names: Vec<String> = active_groups
84            .iter()
85            .map(|(g, _)| sanitize_filename(&g.category))
86            .collect();
87        files.push(GeneratedFile {
88            path: output_base.join("Makefile"),
89            content: render_makefile(&category_names, &header, &ffi_crate_path, &lib_name),
90            generated_header: true,
91        });
92
93        // Generate download_ffi.sh for downloading prebuilt FFI from GitHub releases.
94        let github_repo = alef_config.github_repo();
95        let version = alef_config.resolved_version().unwrap_or_else(|| "0.0.0".to_string());
96        let ffi_pkg_name = e2e_config
97            .registry
98            .packages
99            .get("c")
100            .and_then(|p| p.name.as_ref())
101            .cloned()
102            .unwrap_or_else(|| lib_name.clone());
103        files.push(GeneratedFile {
104            path: output_base.join("download_ffi.sh"),
105            content: render_download_script(&github_repo, &version, &ffi_pkg_name),
106            generated_header: true,
107        });
108
109        // Generate test_runner.h.
110        files.push(GeneratedFile {
111            path: output_base.join("test_runner.h"),
112            content: render_test_runner_header(&active_groups),
113            generated_header: true,
114        });
115
116        // Generate main.c.
117        files.push(GeneratedFile {
118            path: output_base.join("main.c"),
119            content: render_main_c(&active_groups),
120            generated_header: true,
121        });
122
123        let field_resolver = FieldResolver::new(
124            &e2e_config.fields,
125            &e2e_config.fields_optional,
126            &e2e_config.result_fields,
127            &e2e_config.fields_array,
128        );
129
130        // Generate per-category test files.
131        // Each fixture may reference a named call config (fixture.call), so we pass
132        // e2e_config to render_test_file so it can resolve per-fixture call settings.
133        for (group, active) in &active_groups {
134            let filename = format!("test_{}.c", sanitize_filename(&group.category));
135            let content = render_test_file(
136                &group.category,
137                active,
138                &header,
139                &prefix,
140                result_var,
141                e2e_config,
142                lang,
143                &field_resolver,
144            );
145            files.push(GeneratedFile {
146                path: output_base.join(filename),
147                content,
148                generated_header: true,
149            });
150        }
151
152        Ok(files)
153    }
154
155    fn language_name(&self) -> &'static str {
156        "c"
157    }
158}
159
160/// Resolve per-call-config C-specific settings for a given call config and lang.
161struct ResolvedCallInfo {
162    function_name: String,
163    result_type_name: String,
164    client_factory: Option<String>,
165    args: Vec<crate::config::ArgMapping>,
166}
167
168fn resolve_call_info(call: &CallConfig, lang: &str) -> ResolvedCallInfo {
169    let overrides = call.overrides.get(lang);
170    let function_name = overrides
171        .and_then(|o| o.function.as_ref())
172        .cloned()
173        .unwrap_or_else(|| call.function.clone());
174    let result_type_name = overrides
175        .and_then(|o| o.result_type.as_ref())
176        .cloned()
177        .unwrap_or_else(|| function_name.to_pascal_case());
178    let client_factory = overrides.and_then(|o| o.client_factory.as_ref()).cloned();
179    ResolvedCallInfo {
180        function_name,
181        result_type_name,
182        client_factory,
183        args: call.args.clone(),
184    }
185}
186
187/// Resolve call info for a fixture, with fallback to default call's client_factory.
188///
189/// Named call configs (e.g. `[e2e.calls.embed]`) may not repeat the `client_factory`
190/// setting. We fall back to the default `[e2e.call]` override's client_factory so that
191/// all methods on the same client use the same pattern.
192fn resolve_fixture_call_info(fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) -> ResolvedCallInfo {
193    let call = e2e_config.resolve_call(fixture.call.as_deref());
194    let mut info = resolve_call_info(call, lang);
195
196    // Fallback: if the named call has no client_factory override, inherit from the
197    // default call config so all calls use the same client pattern.
198    if info.client_factory.is_none() {
199        let default_overrides = e2e_config.call.overrides.get(lang);
200        if let Some(factory) = default_overrides.and_then(|o| o.client_factory.as_ref()) {
201            info.client_factory = Some(factory.clone());
202        }
203    }
204
205    info
206}
207
208fn render_makefile(categories: &[String], header_name: &str, ffi_crate_path: &str, lib_name: &str) -> String {
209    let mut out = String::new();
210    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
211    let _ = writeln!(out, "CC = gcc");
212    let _ = writeln!(out, "FFI_DIR = ffi");
213    let _ = writeln!(out);
214
215    // 3-path fallback: ffi/ (download script) -> local repo build -> pkg-config.
216    let _ = writeln!(out, "ifneq ($(wildcard $(FFI_DIR)/include/{header_name}),)");
217    let _ = writeln!(out, "    CFLAGS = -Wall -Wextra -I. -I$(FFI_DIR)/include");
218    let _ = writeln!(
219        out,
220        "    LDFLAGS = -L$(FFI_DIR)/lib -l{lib_name} -Wl,-rpath,$(FFI_DIR)/lib"
221    );
222    let _ = writeln!(out, "else ifneq ($(wildcard {ffi_crate_path}/include/{header_name}),)");
223    let _ = writeln!(out, "    CFLAGS = -Wall -Wextra -I. -I{ffi_crate_path}/include");
224    let _ = writeln!(
225        out,
226        "    LDFLAGS = -L../../target/release -l{lib_name} -Wl,-rpath,../../target/release"
227    );
228    let _ = writeln!(out, "else");
229    let _ = writeln!(
230        out,
231        "    CFLAGS = -Wall -Wextra -I. $(shell pkg-config --cflags {lib_name} 2>/dev/null)"
232    );
233    let _ = writeln!(out, "    LDFLAGS = $(shell pkg-config --libs {lib_name} 2>/dev/null)");
234    let _ = writeln!(out, "endif");
235    let _ = writeln!(out);
236
237    let src_files: Vec<String> = categories.iter().map(|c| format!("test_{c}.c")).collect();
238    let srcs = src_files.join(" ");
239
240    let _ = writeln!(out, "SRCS = main.c {srcs}");
241    let _ = writeln!(out, "TARGET = run_tests");
242    let _ = writeln!(out);
243    let _ = writeln!(out, ".PHONY: all clean test");
244    let _ = writeln!(out);
245    let _ = writeln!(out, "all: $(TARGET)");
246    let _ = writeln!(out);
247    let _ = writeln!(out, "$(TARGET): $(SRCS)");
248    let _ = writeln!(out, "\t$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)");
249    let _ = writeln!(out);
250    let _ = writeln!(out, "test: $(TARGET)");
251    let _ = writeln!(out, "\t./$(TARGET)");
252    let _ = writeln!(out);
253    let _ = writeln!(out, "clean:");
254    let _ = writeln!(out, "\trm -f $(TARGET)");
255    out
256}
257
258fn render_download_script(github_repo: &str, version: &str, ffi_pkg_name: &str) -> String {
259    let mut out = String::new();
260    let _ = writeln!(out, "#!/usr/bin/env bash");
261    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
262    let _ = writeln!(out, "set -euo pipefail");
263    let _ = writeln!(out);
264    let _ = writeln!(out, "REPO_URL=\"{github_repo}\"");
265    let _ = writeln!(out, "VERSION=\"{version}\"");
266    let _ = writeln!(out, "FFI_PKG_NAME=\"{ffi_pkg_name}\"");
267    let _ = writeln!(out, "FFI_DIR=\"ffi\"");
268    let _ = writeln!(out);
269    let _ = writeln!(out, "# Detect OS and architecture.");
270    let _ = writeln!(out, "OS=\"$(uname -s | tr '[:upper:]' '[:lower:]')\"");
271    let _ = writeln!(out, "ARCH=\"$(uname -m)\"");
272    let _ = writeln!(out);
273    let _ = writeln!(out, "case \"$ARCH\" in");
274    let _ = writeln!(out, "    x86_64|amd64) ARCH=\"x86_64\" ;;");
275    let _ = writeln!(out, "    arm64|aarch64) ARCH=\"aarch64\" ;;");
276    let _ = writeln!(out, "    *) echo \"Unsupported architecture: $ARCH\" >&2; exit 1 ;;");
277    let _ = writeln!(out, "esac");
278    let _ = writeln!(out);
279    let _ = writeln!(out, "case \"$OS\" in");
280    let _ = writeln!(out, "    linux)  TRIPLE=\"${{ARCH}}-unknown-linux-gnu\" ;;");
281    let _ = writeln!(out, "    darwin) TRIPLE=\"${{ARCH}}-apple-darwin\" ;;");
282    let _ = writeln!(out, "    *)      echo \"Unsupported OS: $OS\" >&2; exit 1 ;;");
283    let _ = writeln!(out, "esac");
284    let _ = writeln!(out);
285    let _ = writeln!(out, "ARCHIVE=\"${{FFI_PKG_NAME}}-${{TRIPLE}}.tar.gz\"");
286    let _ = writeln!(
287        out,
288        "URL=\"${{REPO_URL}}/releases/download/v${{VERSION}}/${{ARCHIVE}}\""
289    );
290    let _ = writeln!(out);
291    let _ = writeln!(out, "echo \"Downloading ${{ARCHIVE}} from v${{VERSION}}...\"");
292    let _ = writeln!(out, "mkdir -p \"$FFI_DIR\"");
293    let _ = writeln!(out, "curl -fSL \"$URL\" | tar xz -C \"$FFI_DIR\"");
294    let _ = writeln!(out, "echo \"FFI library extracted to $FFI_DIR/\"");
295    out
296}
297
298fn render_test_runner_header(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
299    let mut out = String::new();
300    let _ = writeln!(out, "/* This file is auto-generated by alef. DO NOT EDIT. */");
301    let _ = writeln!(out, "#ifndef TEST_RUNNER_H");
302    let _ = writeln!(out, "#define TEST_RUNNER_H");
303    let _ = writeln!(out);
304    let _ = writeln!(out, "#include <string.h>");
305    let _ = writeln!(out, "#include <stdlib.h>");
306    let _ = writeln!(out);
307    // Trim helper for comparing strings that may have trailing whitespace/newlines.
308    let _ = writeln!(out, "/**");
309    let _ = writeln!(
310        out,
311        " * Compare a string against an expected value, trimming trailing whitespace."
312    );
313    let _ = writeln!(
314        out,
315        " * Returns 0 if the trimmed actual string equals the expected string."
316    );
317    let _ = writeln!(out, " */");
318    let _ = writeln!(
319        out,
320        "static inline int str_trim_eq(const char *actual, const char *expected) {{"
321    );
322    let _ = writeln!(
323        out,
324        "    if (actual == NULL || expected == NULL) return actual != expected;"
325    );
326    let _ = writeln!(out, "    size_t alen = strlen(actual);");
327    let _ = writeln!(
328        out,
329        "    while (alen > 0 && (actual[alen-1] == ' ' || actual[alen-1] == '\\n' || actual[alen-1] == '\\r' || actual[alen-1] == '\\t')) alen--;"
330    );
331    let _ = writeln!(out, "    size_t elen = strlen(expected);");
332    let _ = writeln!(out, "    if (alen != elen) return 1;");
333    let _ = writeln!(out, "    return memcmp(actual, expected, elen);");
334    let _ = writeln!(out, "}}");
335    let _ = writeln!(out);
336
337    let _ = writeln!(out, "/**");
338    let _ = writeln!(
339        out,
340        " * Extract a string value for a given key from a JSON object string."
341    );
342    let _ = writeln!(
343        out,
344        " * Returns a heap-allocated copy of the value, or NULL if not found."
345    );
346    let _ = writeln!(out, " * Caller must free() the returned string.");
347    let _ = writeln!(out, " */");
348    let _ = writeln!(
349        out,
350        "static inline char *alef_json_get_string(const char *json, const char *key) {{"
351    );
352    let _ = writeln!(out, "    if (json == NULL || key == NULL) return NULL;");
353    let _ = writeln!(out, "    /* Build search pattern: \"key\":  */");
354    let _ = writeln!(out, "    size_t key_len = strlen(key);");
355    let _ = writeln!(out, "    char *pattern = (char *)malloc(key_len + 5);");
356    let _ = writeln!(out, "    if (!pattern) return NULL;");
357    let _ = writeln!(out, "    pattern[0] = '\"';");
358    let _ = writeln!(out, "    memcpy(pattern + 1, key, key_len);");
359    let _ = writeln!(out, "    pattern[key_len + 1] = '\"';");
360    let _ = writeln!(out, "    pattern[key_len + 2] = ':';");
361    let _ = writeln!(out, "    pattern[key_len + 3] = '\\0';");
362    let _ = writeln!(out, "    const char *found = strstr(json, pattern);");
363    let _ = writeln!(out, "    free(pattern);");
364    let _ = writeln!(out, "    if (!found) return NULL;");
365    let _ = writeln!(out, "    found += key_len + 3; /* skip past \"key\": */");
366    let _ = writeln!(out, "    while (*found == ' ' || *found == '\\t') found++;");
367    let _ = writeln!(out, "    if (*found != '\"') return NULL; /* not a string value */");
368    let _ = writeln!(out, "    found++; /* skip opening quote */");
369    let _ = writeln!(out, "    const char *end = found;");
370    let _ = writeln!(out, "    while (*end && *end != '\"') {{");
371    let _ = writeln!(out, "        if (*end == '\\\\') {{ end++; if (*end) end++; }}");
372    let _ = writeln!(out, "        else end++;");
373    let _ = writeln!(out, "    }}");
374    let _ = writeln!(out, "    size_t val_len = (size_t)(end - found);");
375    let _ = writeln!(out, "    char *result_str = (char *)malloc(val_len + 1);");
376    let _ = writeln!(out, "    if (!result_str) return NULL;");
377    let _ = writeln!(out, "    memcpy(result_str, found, val_len);");
378    let _ = writeln!(out, "    result_str[val_len] = '\\0';");
379    let _ = writeln!(out, "    return result_str;");
380    let _ = writeln!(out, "}}");
381    let _ = writeln!(out);
382    let _ = writeln!(out, "/**");
383    let _ = writeln!(out, " * Count top-level elements in a JSON array string.");
384    let _ = writeln!(out, " * Returns 0 for empty arrays (\"[]\") or NULL input.");
385    let _ = writeln!(out, " */");
386    let _ = writeln!(out, "static inline int alef_json_array_count(const char *json) {{");
387    let _ = writeln!(out, "    if (json == NULL) return 0;");
388    let _ = writeln!(out, "    /* Skip leading whitespace */");
389    let _ = writeln!(
390        out,
391        "    while (*json == ' ' || *json == '\\t' || *json == '\\n') json++;"
392    );
393    let _ = writeln!(out, "    if (*json != '[') return 0;");
394    let _ = writeln!(out, "    json++;");
395    let _ = writeln!(out, "    /* Skip whitespace after '[' */");
396    let _ = writeln!(
397        out,
398        "    while (*json == ' ' || *json == '\\t' || *json == '\\n') json++;"
399    );
400    let _ = writeln!(out, "    if (*json == ']') return 0;");
401    let _ = writeln!(out, "    int count = 1;");
402    let _ = writeln!(out, "    int depth = 0;");
403    let _ = writeln!(out, "    int in_string = 0;");
404    let _ = writeln!(
405        out,
406        "    for (; *json && !(*json == ']' && depth == 0 && !in_string); json++) {{"
407    );
408    let _ = writeln!(out, "        if (*json == '\\\\' && in_string) {{ json++; continue; }}");
409    let _ = writeln!(
410        out,
411        "        if (*json == '\"') {{ in_string = !in_string; continue; }}"
412    );
413    let _ = writeln!(out, "        if (in_string) continue;");
414    let _ = writeln!(out, "        if (*json == '[' || *json == '{{') depth++;");
415    let _ = writeln!(out, "        else if (*json == ']' || *json == '}}') depth--;");
416    let _ = writeln!(out, "        else if (*json == ',' && depth == 0) count++;");
417    let _ = writeln!(out, "    }}");
418    let _ = writeln!(out, "    return count;");
419    let _ = writeln!(out, "}}");
420    let _ = writeln!(out);
421
422    for (group, fixtures) in active_groups {
423        let _ = writeln!(out, "/* Tests for category: {} */", group.category);
424        for fixture in fixtures {
425            let fn_name = sanitize_ident(&fixture.id);
426            let _ = writeln!(out, "void test_{fn_name}(void);");
427        }
428        let _ = writeln!(out);
429    }
430
431    let _ = writeln!(out, "#endif /* TEST_RUNNER_H */");
432    out
433}
434
435fn render_main_c(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
436    let mut out = String::new();
437    let _ = writeln!(out, "/* This file is auto-generated by alef. DO NOT EDIT. */");
438    let _ = writeln!(out, "#include <stdio.h>");
439    let _ = writeln!(out, "#include \"test_runner.h\"");
440    let _ = writeln!(out);
441    let _ = writeln!(out, "int main(void) {{");
442    let _ = writeln!(out, "    int passed = 0;");
443    let _ = writeln!(out, "    int failed = 0;");
444    let _ = writeln!(out);
445
446    for (group, fixtures) in active_groups {
447        let _ = writeln!(out, "    /* Category: {} */", group.category);
448        for fixture in fixtures {
449            let fn_name = sanitize_ident(&fixture.id);
450            let _ = writeln!(out, "    printf(\"  Running test_{fn_name}...\");");
451            let _ = writeln!(out, "    test_{fn_name}();");
452            let _ = writeln!(out, "    printf(\" PASSED\\n\");");
453            let _ = writeln!(out, "    passed++;");
454        }
455        let _ = writeln!(out);
456    }
457
458    let _ = writeln!(
459        out,
460        "    printf(\"\\nResults: %d passed, %d failed\\n\", passed, failed);"
461    );
462    let _ = writeln!(out, "    return failed > 0 ? 1 : 0;");
463    let _ = writeln!(out, "}}");
464    out
465}
466
467#[allow(clippy::too_many_arguments)]
468fn render_test_file(
469    category: &str,
470    fixtures: &[&Fixture],
471    header: &str,
472    prefix: &str,
473    result_var: &str,
474    e2e_config: &E2eConfig,
475    lang: &str,
476    field_resolver: &FieldResolver,
477) -> String {
478    let mut out = String::new();
479    let _ = writeln!(out, "/* This file is auto-generated by alef. DO NOT EDIT. */");
480    let _ = writeln!(out, "/* E2e tests for category: {category} */");
481    let _ = writeln!(out);
482    let _ = writeln!(out, "#include <assert.h>");
483    let _ = writeln!(out, "#include <string.h>");
484    let _ = writeln!(out, "#include <stdio.h>");
485    let _ = writeln!(out, "#include <stdlib.h>");
486    let _ = writeln!(out, "#include \"{header}\"");
487    let _ = writeln!(out, "#include \"test_runner.h\"");
488    let _ = writeln!(out);
489
490    for (i, fixture) in fixtures.iter().enumerate() {
491        // Skip fixtures with visitor specs: C visitor support not yet implemented.
492        if fixture.visitor.is_some() {
493            let _ = writeln!(
494                out,
495                "/* TODO: {} - visitor pattern not supported in C yet */",
496                fixture.id
497            );
498            continue;
499        }
500
501        let call_info = resolve_fixture_call_info(fixture, e2e_config, lang);
502        render_test_function(
503            &mut out,
504            fixture,
505            prefix,
506            &call_info.function_name,
507            result_var,
508            &call_info.args,
509            field_resolver,
510            &e2e_config.fields_c_types,
511            &call_info.result_type_name,
512            call_info.client_factory.as_deref(),
513        );
514        if i + 1 < fixtures.len() {
515            let _ = writeln!(out);
516        }
517    }
518
519    out
520}
521
522#[allow(clippy::too_many_arguments)]
523fn render_test_function(
524    out: &mut String,
525    fixture: &Fixture,
526    prefix: &str,
527    function_name: &str,
528    result_var: &str,
529    args: &[crate::config::ArgMapping],
530    field_resolver: &FieldResolver,
531    fields_c_types: &HashMap<String, String>,
532    result_type_name: &str,
533    client_factory: Option<&str>,
534) {
535    let fn_name = sanitize_ident(&fixture.id);
536    let description = &fixture.description;
537
538    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
539
540    let _ = writeln!(out, "void test_{fn_name}(void) {{");
541    let _ = writeln!(out, "    /* {description} */");
542
543    let prefix_upper = prefix.to_uppercase();
544
545    // Client pattern: used when client_factory is configured (e.g. liter-llm).
546    // Builds typed request handles from json_object args, creates a client via the
547    // factory function, calls {prefix}_default_client_{function_name}(client, req),
548    // then frees result, request handles, and client.
549    if let Some(factory) = client_factory {
550        let mut request_handle_vars: Vec<(String, String)> = Vec::new(); // (arg_name, var_name)
551
552        for arg in args {
553            if arg.arg_type == "json_object" {
554                // Derive request type from result type: strip "Response", append "Request".
555                // E.g. "ChatCompletionResponse" -> "ChatCompletionRequest".
556                let request_type_pascal = if let Some(stripped) = result_type_name.strip_suffix("Response") {
557                    format!("{}Request", stripped)
558                } else {
559                    format!("{result_type_name}Request")
560                };
561                let request_type_snake = request_type_pascal.to_snake_case();
562                let var_name = format!("{request_type_snake}_handle");
563
564                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
565                let json_val = if field.is_empty() || field == "input" {
566                    Some(&fixture.input)
567                } else {
568                    fixture.input.get(field)
569                };
570
571                if let Some(val) = json_val {
572                    if !val.is_null() {
573                        let normalized = super::normalize_json_keys_to_snake_case(val);
574                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
575                        let escaped = escape_c(&json_str);
576                        let _ = writeln!(
577                            out,
578                            "    {prefix_upper}{request_type_pascal}* {var_name} = \
579                             {prefix}_{request_type_snake}_from_json(\"{escaped}\");"
580                        );
581                        let _ = writeln!(out, "    assert({var_name} != NULL && \"failed to build request\");");
582                        request_handle_vars.push((arg.name.clone(), var_name));
583                    }
584                }
585            }
586        }
587
588        let _ = writeln!(
589            out,
590            "    {prefix_upper}DefaultClient* client = {prefix}_{factory}(\"test-key\", NULL, 0, 0, NULL);"
591        );
592        let _ = writeln!(out, "    assert(client != NULL && \"failed to create client\");");
593
594        let method_args = if request_handle_vars.is_empty() {
595            String::new()
596        } else {
597            let handles: Vec<&str> = request_handle_vars.iter().map(|(_, v)| v.as_str()).collect();
598            format!(", {}", handles.join(", "))
599        };
600
601        let call_fn = format!("{prefix}_default_client_{function_name}");
602
603        if expects_error {
604            let _ = writeln!(
605                out,
606                "    {prefix_upper}{result_type_name}* {result_var} = {call_fn}(client{method_args});"
607            );
608            for (_, var_name) in &request_handle_vars {
609                let req_snake = var_name.strip_suffix("_handle").unwrap_or(var_name);
610                let _ = writeln!(out, "    {prefix}_{req_snake}_free({var_name});");
611            }
612            let _ = writeln!(out, "    {prefix}_default_client_free(client);");
613            let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
614            let _ = writeln!(out, "}}");
615            return;
616        }
617
618        let _ = writeln!(
619            out,
620            "    {prefix_upper}{result_type_name}* {result_var} = {call_fn}(client{method_args});"
621        );
622        let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
623
624        let mut intermediate_handles: Vec<(String, String)> = Vec::new();
625        let mut accessed_fields: Vec<(String, String, bool)> = Vec::new();
626
627        for assertion in &fixture.assertions {
628            if let Some(f) = &assertion.field {
629                if !f.is_empty() && !accessed_fields.iter().any(|(k, _, _)| k == f) {
630                    let resolved = field_resolver.resolve(f);
631                    let local_var = f.replace(['.', '['], "_").replace(']', "");
632                    let has_map_access = resolved.contains('[');
633                    if resolved.contains('.') {
634                        emit_nested_accessor(
635                            out,
636                            prefix,
637                            resolved,
638                            &local_var,
639                            result_var,
640                            fields_c_types,
641                            &mut intermediate_handles,
642                            result_type_name,
643                        );
644                    } else {
645                        let result_type_snake = result_type_name.to_snake_case();
646                        let accessor_fn = format!("{prefix}_{result_type_snake}_{resolved}");
647                        let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({result_var});");
648                    }
649                    accessed_fields.push((f.clone(), local_var, has_map_access));
650                }
651            }
652        }
653
654        for assertion in &fixture.assertions {
655            render_assertion(out, assertion, result_var, field_resolver, &accessed_fields);
656        }
657
658        for (_f, local_var, from_json) in &accessed_fields {
659            if *from_json {
660                let _ = writeln!(out, "    free({local_var});");
661            } else {
662                let _ = writeln!(out, "    {prefix}_free_string({local_var});");
663            }
664        }
665        for (handle_var, snake_type) in intermediate_handles.iter().rev() {
666            if snake_type == "free_string" {
667                let _ = writeln!(out, "    {prefix}_free_string({handle_var});");
668            } else {
669                let _ = writeln!(out, "    {prefix}_{snake_type}_free({handle_var});");
670            }
671        }
672        let result_type_snake = result_type_name.to_snake_case();
673        let _ = writeln!(out, "    {prefix}_{result_type_snake}_free({result_var});");
674        for (_, var_name) in &request_handle_vars {
675            let req_snake = var_name.strip_suffix("_handle").unwrap_or(var_name);
676            let _ = writeln!(out, "    {prefix}_{req_snake}_free({var_name});");
677        }
678        let _ = writeln!(out, "    {prefix}_default_client_free(client);");
679        let _ = writeln!(out, "}}");
680        return;
681    }
682
683    // Legacy (non-client) path: call the function directly.
684    // Used for libraries like html-to-markdown that expose standalone FFI functions.
685
686    // Use the function name directly — the override already includes the prefix
687    // (e.g. "htm_convert"), so we must NOT prepend it again.
688    let prefixed_fn = function_name.to_string();
689
690    // For json_object args, emit a from_json call to construct the options handle.
691    let mut has_options_handle = false;
692    for arg in args {
693        if arg.arg_type == "json_object" {
694            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
695            if let Some(val) = fixture.input.get(field) {
696                if !val.is_null() {
697                    // Fixture keys are camelCase; the FFI htm_conversion_options_from_json
698                    // deserializes into the Rust ConversionOptions type which uses default
699                    // serde (snake_case). Normalize keys before serializing.
700                    let normalized = super::normalize_json_keys_to_snake_case(val);
701                    let json_str = serde_json::to_string(&normalized).unwrap_or_default();
702                    let escaped = escape_c(&json_str);
703                    let upper = prefix.to_uppercase();
704                    let _ = writeln!(
705                        out,
706                        "    {upper}ConversionOptions* options_handle = {prefix}_conversion_options_from_json(\"{escaped}\");"
707                    );
708                    has_options_handle = true;
709                }
710            }
711        }
712    }
713
714    let args_str = build_args_string_c(&fixture.input, args, has_options_handle);
715
716    if expects_error {
717        let _ = writeln!(
718            out,
719            "    {prefix_upper}{result_type_name}* {result_var} = {prefixed_fn}({args_str});"
720        );
721        if has_options_handle {
722            let _ = writeln!(out, "    {prefix}_conversion_options_free(options_handle);");
723        }
724        let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
725        let _ = writeln!(out, "}}");
726        return;
727    }
728
729    // The FFI returns an opaque handle; extract the content string from it.
730    let _ = writeln!(
731        out,
732        "    {prefix_upper}{result_type_name}* {result_var} = {prefixed_fn}({args_str});"
733    );
734    let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
735
736    // Collect fields accessed by assertions so we can emit accessor calls.
737    // C FFI uses the opaque handle pattern: {prefix}_conversion_result_{field}(handle).
738    // For nested paths we generate chained FFI accessor calls using the type
739    // chain from `fields_c_types`.
740    // Each entry: (fixture_field, local_var, from_json_extract).
741    // `from_json_extract` is true when the variable was extracted from a JSON
742    // map via alef_json_get_string and needs free() instead of {prefix}_free_string().
743    let mut accessed_fields: Vec<(String, String, bool)> = Vec::new();
744    // Track intermediate handles emitted so we can free them and avoid duplicates.
745    // Each entry: (handle_var_name, snake_type_name) — freed in reverse order.
746    let mut intermediate_handles: Vec<(String, String)> = Vec::new();
747
748    for assertion in &fixture.assertions {
749        if let Some(f) = &assertion.field {
750            if !f.is_empty() && !accessed_fields.iter().any(|(k, _, _)| k == f) {
751                let resolved = field_resolver.resolve(f);
752                let local_var = f.replace(['.', '['], "_").replace(']', "");
753                let has_map_access = resolved.contains('[');
754
755                if resolved.contains('.') {
756                    emit_nested_accessor(
757                        out,
758                        prefix,
759                        resolved,
760                        &local_var,
761                        result_var,
762                        fields_c_types,
763                        &mut intermediate_handles,
764                        result_type_name,
765                    );
766                } else {
767                    let result_type_snake = result_type_name.to_snake_case();
768                    let accessor_fn = format!("{prefix}_{result_type_snake}_{resolved}");
769                    let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({result_var});");
770                }
771                accessed_fields.push((f.clone(), local_var.clone(), has_map_access));
772            }
773        }
774    }
775
776    for assertion in &fixture.assertions {
777        render_assertion(out, assertion, result_var, field_resolver, &accessed_fields);
778    }
779
780    // Free extracted leaf strings.
781    for (_f, local_var, from_json) in &accessed_fields {
782        if *from_json {
783            let _ = writeln!(out, "    free({local_var});");
784        } else {
785            let _ = writeln!(out, "    {prefix}_free_string({local_var});");
786        }
787    }
788    // Free intermediate handles in reverse order.
789    for (handle_var, snake_type) in intermediate_handles.iter().rev() {
790        if snake_type == "free_string" {
791            // free_string handles are freed with the free_string function directly.
792            let _ = writeln!(out, "    {prefix}_free_string({handle_var});");
793        } else {
794            let _ = writeln!(out, "    {prefix}_{snake_type}_free({handle_var});");
795        }
796    }
797    if has_options_handle {
798        let _ = writeln!(out, "    {prefix}_conversion_options_free(options_handle);");
799    }
800    let result_type_snake = result_type_name.to_snake_case();
801    let _ = writeln!(out, "    {prefix}_{result_type_snake}_free({result_var});");
802    let _ = writeln!(out, "}}");
803}
804
805/// Emit chained FFI accessor calls for a nested resolved field path.
806///
807/// For a path like `metadata.document.title`, this generates:
808/// ```c
809/// HTMHtmlMetadata* metadata_handle = htm_conversion_result_metadata(result);
810/// assert(metadata_handle != NULL);
811/// HTMDocumentMetadata* doc_handle = htm_html_metadata_document(metadata_handle);
812/// assert(doc_handle != NULL);
813/// char* metadata_title = htm_document_metadata_title(doc_handle);
814/// ```
815///
816/// The type chain is looked up from `fields_c_types` which maps
817/// `"{parent_snake_type}.{field}"` -> `"PascalCaseType"`.
818#[allow(clippy::too_many_arguments)]
819fn emit_nested_accessor(
820    out: &mut String,
821    prefix: &str,
822    resolved: &str,
823    local_var: &str,
824    result_var: &str,
825    fields_c_types: &HashMap<String, String>,
826    intermediate_handles: &mut Vec<(String, String)>,
827    result_type_name: &str,
828) {
829    let segments: Vec<&str> = resolved.split('.').collect();
830    let prefix_upper = prefix.to_uppercase();
831
832    // Walk the path, starting from the root result type.
833    let mut current_snake_type = result_type_name.to_snake_case();
834    let mut current_handle = result_var.to_string();
835
836    for (i, segment) in segments.iter().enumerate() {
837        let is_leaf = i + 1 == segments.len();
838
839        // Check for map access: "field[key]"
840        if let Some(bracket_pos) = segment.find('[') {
841            let field_name = &segment[..bracket_pos];
842            let key = segment[bracket_pos + 1..].trim_end_matches(']');
843            let field_snake = field_name.to_snake_case();
844            let accessor_fn = format!("{prefix}_{current_snake_type}_{field_snake}");
845
846            // The map accessor returns a char* (JSON object string).
847            // Use alef_json_get_string to extract the key value.
848            let json_var = format!("{field_snake}_json");
849            if !intermediate_handles.iter().any(|(h, _)| h == &json_var) {
850                let _ = writeln!(out, "    char* {json_var} = {accessor_fn}({current_handle});");
851                let _ = writeln!(out, "    assert({json_var} != NULL);");
852                // Track for freeing — use prefix_free_string since it's a char*.
853                intermediate_handles.push((json_var.clone(), "free_string".to_string()));
854            }
855            // Extract the key from the JSON map.
856            let _ = writeln!(
857                out,
858                "    char* {local_var} = alef_json_get_string({json_var}, \"{key}\");"
859            );
860            return; // Map access is always the leaf.
861        }
862
863        let seg_snake = segment.to_snake_case();
864        let accessor_fn = format!("{prefix}_{current_snake_type}_{seg_snake}");
865
866        if is_leaf {
867            // Leaf field returns char* — assign to the local variable.
868            let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({current_handle});");
869        } else {
870            // Intermediate field returns an opaque handle.
871            let lookup_key = format!("{current_snake_type}.{seg_snake}");
872            let return_type_pascal = match fields_c_types.get(&lookup_key) {
873                Some(t) => t.clone(),
874                None => {
875                    // Fallback: derive PascalCase from the segment name itself.
876                    segment.to_pascal_case()
877                }
878            };
879            let return_snake = return_type_pascal.to_snake_case();
880            let handle_var = format!("{seg_snake}_handle");
881
882            // Only emit the handle if we haven't already (multiple fields may
883            // share the same intermediate path prefix).
884            if !intermediate_handles.iter().any(|(h, _)| h == &handle_var) {
885                let _ = writeln!(
886                    out,
887                    "    {prefix_upper}{return_type_pascal}* {handle_var} = \
888                     {accessor_fn}({current_handle});"
889                );
890                let _ = writeln!(out, "    assert({handle_var} != NULL);");
891                intermediate_handles.push((handle_var.clone(), return_snake.clone()));
892            }
893
894            current_snake_type = return_snake;
895            current_handle = handle_var;
896        }
897    }
898}
899
900/// Build the C argument string for the function call.
901/// When `has_options_handle` is true, json_object args are replaced with
902/// the `options_handle` pointer (which was constructed via `from_json`).
903fn build_args_string_c(
904    input: &serde_json::Value,
905    args: &[crate::config::ArgMapping],
906    has_options_handle: bool,
907) -> String {
908    if args.is_empty() {
909        return json_to_c(input);
910    }
911
912    let parts: Vec<String> = args
913        .iter()
914        .filter_map(|arg| {
915            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
916            let val = input.get(field);
917            match val {
918                // Field missing entirely and optional → pass NULL.
919                None if arg.optional => Some("NULL".to_string()),
920                // Field missing and required → skip (caller error, but don't crash).
921                None => None,
922                // Explicit null on optional arg → pass NULL.
923                Some(v) if v.is_null() && arg.optional => Some("NULL".to_string()),
924                Some(v) => {
925                    // For json_object args, use the options_handle pointer
926                    // instead of the raw JSON string.
927                    if arg.arg_type == "json_object" && has_options_handle && !v.is_null() {
928                        Some("options_handle".to_string())
929                    } else {
930                        Some(json_to_c(v))
931                    }
932                }
933            }
934        })
935        .collect();
936
937    parts.join(", ")
938}
939
940fn render_assertion(
941    out: &mut String,
942    assertion: &Assertion,
943    result_var: &str,
944    _field_resolver: &FieldResolver,
945    accessed_fields: &[(String, String, bool)],
946) {
947    // Skip assertions on fields that don't exist on the result type.
948    if let Some(f) = &assertion.field {
949        if !f.is_empty() && !_field_resolver.is_valid_for_result(f) {
950            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
951            return;
952        }
953    }
954
955    let field_expr = match &assertion.field {
956        Some(f) if !f.is_empty() => {
957            // Use the local variable extracted from the opaque handle.
958            accessed_fields
959                .iter()
960                .find(|(k, _, _)| k == f)
961                .map(|(_, local, _)| local.clone())
962                .unwrap_or_else(|| result_var.to_string())
963        }
964        _ => result_var.to_string(),
965    };
966
967    match assertion.assertion_type.as_str() {
968        "equals" => {
969            if let Some(expected) = &assertion.value {
970                let c_val = json_to_c(expected);
971                if expected.is_string() {
972                    // Use str_trim_eq for string comparisons to handle trailing whitespace.
973                    let _ = writeln!(
974                        out,
975                        "    assert(str_trim_eq({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
976                    );
977                } else {
978                    let _ = writeln!(
979                        out,
980                        "    assert(strcmp({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
981                    );
982                }
983            }
984        }
985        "contains" => {
986            if let Some(expected) = &assertion.value {
987                let c_val = json_to_c(expected);
988                let _ = writeln!(
989                    out,
990                    "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
991                );
992            }
993        }
994        "contains_all" => {
995            if let Some(values) = &assertion.values {
996                for val in values {
997                    let c_val = json_to_c(val);
998                    let _ = writeln!(
999                        out,
1000                        "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
1001                    );
1002                }
1003            }
1004        }
1005        "not_contains" => {
1006            if let Some(expected) = &assertion.value {
1007                let c_val = json_to_c(expected);
1008                let _ = writeln!(
1009                    out,
1010                    "    assert(strstr({field_expr}, {c_val}) == NULL && \"expected NOT to contain substring\");"
1011                );
1012            }
1013        }
1014        "not_empty" => {
1015            let _ = writeln!(
1016                out,
1017                "    assert(strlen({field_expr}) > 0 && \"expected non-empty value\");"
1018            );
1019        }
1020        "is_empty" => {
1021            let _ = writeln!(
1022                out,
1023                "    assert(strlen({field_expr}) == 0 && \"expected empty value\");"
1024            );
1025        }
1026        "contains_any" => {
1027            if let Some(values) = &assertion.values {
1028                let _ = writeln!(out, "    {{");
1029                let _ = writeln!(out, "        int found = 0;");
1030                for val in values {
1031                    let c_val = json_to_c(val);
1032                    let _ = writeln!(
1033                        out,
1034                        "        if (strstr({field_expr}, {c_val}) != NULL) {{ found = 1; }}"
1035                    );
1036                }
1037                let _ = writeln!(
1038                    out,
1039                    "        assert(found && \"expected to contain at least one of the specified values\");"
1040                );
1041                let _ = writeln!(out, "    }}");
1042            }
1043        }
1044        "greater_than" => {
1045            if let Some(val) = &assertion.value {
1046                let c_val = json_to_c(val);
1047                let _ = writeln!(out, "    assert({field_expr} > {c_val} && \"expected greater than\");");
1048            }
1049        }
1050        "less_than" => {
1051            if let Some(val) = &assertion.value {
1052                let c_val = json_to_c(val);
1053                let _ = writeln!(out, "    assert({field_expr} < {c_val} && \"expected less than\");");
1054            }
1055        }
1056        "greater_than_or_equal" => {
1057            if let Some(val) = &assertion.value {
1058                let c_val = json_to_c(val);
1059                let _ = writeln!(
1060                    out,
1061                    "    assert({field_expr} >= {c_val} && \"expected greater than or equal\");"
1062                );
1063            }
1064        }
1065        "less_than_or_equal" => {
1066            if let Some(val) = &assertion.value {
1067                let c_val = json_to_c(val);
1068                let _ = writeln!(
1069                    out,
1070                    "    assert({field_expr} <= {c_val} && \"expected less than or equal\");"
1071                );
1072            }
1073        }
1074        "starts_with" => {
1075            if let Some(expected) = &assertion.value {
1076                let c_val = json_to_c(expected);
1077                let _ = writeln!(
1078                    out,
1079                    "    assert(strncmp({field_expr}, {c_val}, strlen({c_val})) == 0 && \"expected to start with\");"
1080                );
1081            }
1082        }
1083        "ends_with" => {
1084            if let Some(expected) = &assertion.value {
1085                let c_val = json_to_c(expected);
1086                let _ = writeln!(out, "    assert(strlen({field_expr}) >= strlen({c_val}) && ");
1087                let _ = writeln!(
1088                    out,
1089                    "           strcmp({field_expr} + strlen({field_expr}) - strlen({c_val}), {c_val}) == 0 && \"expected to end with\");"
1090                );
1091            }
1092        }
1093        "min_length" => {
1094            if let Some(val) = &assertion.value {
1095                if let Some(n) = val.as_u64() {
1096                    let _ = writeln!(
1097                        out,
1098                        "    assert(strlen({field_expr}) >= {n} && \"expected minimum length\");"
1099                    );
1100                }
1101            }
1102        }
1103        "max_length" => {
1104            if let Some(val) = &assertion.value {
1105                if let Some(n) = val.as_u64() {
1106                    let _ = writeln!(
1107                        out,
1108                        "    assert(strlen({field_expr}) <= {n} && \"expected maximum length\");"
1109                    );
1110                }
1111            }
1112        }
1113        "count_min" => {
1114            if let Some(val) = &assertion.value {
1115                if let Some(n) = val.as_u64() {
1116                    let _ = writeln!(out, "    {{");
1117                    let _ = writeln!(out, "        /* count_min: count top-level JSON array elements */");
1118                    let _ = writeln!(
1119                        out,
1120                        "        assert({field_expr} != NULL && \"expected non-null collection JSON\");"
1121                    );
1122                    let _ = writeln!(out, "        int elem_count = alef_json_array_count({field_expr});");
1123                    let _ = writeln!(
1124                        out,
1125                        "        assert(elem_count >= {n} && \"expected at least {n} elements\");"
1126                    );
1127                    let _ = writeln!(out, "    }}");
1128                }
1129            }
1130        }
1131        "count_equals" => {
1132            if let Some(val) = &assertion.value {
1133                if let Some(n) = val.as_u64() {
1134                    let _ = writeln!(out, "    {{");
1135                    let _ = writeln!(out, "        /* count_equals: count elements in array */");
1136                    let _ = writeln!(
1137                        out,
1138                        "        assert({field_expr} != NULL && \"expected non-null collection JSON\");"
1139                    );
1140                    let _ = writeln!(out, "        int elem_count = alef_json_array_count({field_expr});");
1141                    let _ = writeln!(out, "        assert(elem_count == {n} && \"expected {n} elements\");");
1142                    let _ = writeln!(out, "    }}");
1143                }
1144            }
1145        }
1146        "is_true" => {
1147            let _ = writeln!(out, "    assert({field_expr});");
1148        }
1149        "not_error" => {
1150            // Already handled — the NULL check above covers this.
1151        }
1152        "error" => {
1153            // Handled at the test function level.
1154        }
1155        other => {
1156            let _ = writeln!(out, "    /* TODO: unsupported assertion type: {other} */");
1157        }
1158    }
1159}
1160
1161/// Convert a `serde_json::Value` to a C literal string.
1162fn json_to_c(value: &serde_json::Value) -> String {
1163    match value {
1164        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
1165        serde_json::Value::Bool(true) => "1".to_string(),
1166        serde_json::Value::Bool(false) => "0".to_string(),
1167        serde_json::Value::Number(n) => n.to_string(),
1168        serde_json::Value::Null => "NULL".to_string(),
1169        other => format!("\"{}\"", escape_c(&other.to_string())),
1170    }
1171}