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