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                // Derive request type from result type: strip "Response", append "Request".
591                // E.g. "ChatCompletionResponse" -> "ChatCompletionRequest".
592                let request_type_pascal = if let Some(stripped) = result_type_name.strip_suffix("Response") {
593                    format!("{}Request", stripped)
594                } else {
595                    format!("{result_type_name}Request")
596                };
597                let request_type_snake = request_type_pascal.to_snake_case();
598                let var_name = format!("{request_type_snake}_handle");
599
600                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
601                let json_val = if field.is_empty() || field == "input" {
602                    Some(&fixture.input)
603                } else {
604                    fixture.input.get(field)
605                };
606
607                if let Some(val) = json_val {
608                    if !val.is_null() {
609                        let normalized = super::normalize_json_keys_to_snake_case(val);
610                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
611                        let escaped = escape_c(&json_str);
612                        let _ = writeln!(
613                            out,
614                            "    {prefix_upper}{request_type_pascal}* {var_name} = \
615                             {prefix}_{request_type_snake}_from_json(\"{escaped}\");"
616                        );
617                        let _ = writeln!(out, "    assert({var_name} != NULL && \"failed to build request\");");
618                        request_handle_vars.push((arg.name.clone(), var_name));
619                    }
620                }
621            }
622        }
623
624        let _ = writeln!(
625            out,
626            "    {prefix_upper}DefaultClient* client = {prefix}_{factory}(\"test-key\", NULL, 0, 0, NULL);"
627        );
628        let _ = writeln!(out, "    assert(client != NULL && \"failed to create client\");");
629
630        let method_args = if request_handle_vars.is_empty() {
631            String::new()
632        } else {
633            let handles: Vec<&str> = request_handle_vars.iter().map(|(_, v)| v.as_str()).collect();
634            format!(", {}", handles.join(", "))
635        };
636
637        let call_fn = format!("{prefix}_default_client_{function_name}");
638
639        if expects_error {
640            let _ = writeln!(
641                out,
642                "    {prefix_upper}{result_type_name}* {result_var} = {call_fn}(client{method_args});"
643            );
644            for (_, var_name) in &request_handle_vars {
645                let req_snake = var_name.strip_suffix("_handle").unwrap_or(var_name);
646                let _ = writeln!(out, "    {prefix}_{req_snake}_free({var_name});");
647            }
648            let _ = writeln!(out, "    {prefix}_default_client_free(client);");
649            let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
650            let _ = writeln!(out, "}}");
651            return;
652        }
653
654        let _ = writeln!(
655            out,
656            "    {prefix_upper}{result_type_name}* {result_var} = {call_fn}(client{method_args});"
657        );
658        let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
659
660        let mut intermediate_handles: Vec<(String, String)> = Vec::new();
661        let mut accessed_fields: Vec<(String, String, bool)> = Vec::new();
662
663        for assertion in &fixture.assertions {
664            if let Some(f) = &assertion.field {
665                if !f.is_empty() && !accessed_fields.iter().any(|(k, _, _)| k == f) {
666                    let resolved = field_resolver.resolve(f);
667                    let local_var = f.replace(['.', '['], "_").replace(']', "");
668                    let has_map_access = resolved.contains('[');
669                    if resolved.contains('.') {
670                        emit_nested_accessor(
671                            out,
672                            prefix,
673                            resolved,
674                            &local_var,
675                            result_var,
676                            fields_c_types,
677                            &mut intermediate_handles,
678                            result_type_name,
679                        );
680                    } else {
681                        let result_type_snake = result_type_name.to_snake_case();
682                        let accessor_fn = format!("{prefix}_{result_type_snake}_{resolved}");
683                        let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({result_var});");
684                    }
685                    accessed_fields.push((f.clone(), local_var, has_map_access));
686                }
687            }
688        }
689
690        for assertion in &fixture.assertions {
691            render_assertion(out, assertion, result_var, prefix, field_resolver, &accessed_fields);
692        }
693
694        for (_f, local_var, from_json) in &accessed_fields {
695            if *from_json {
696                let _ = writeln!(out, "    free({local_var});");
697            } else {
698                let _ = writeln!(out, "    {prefix}_free_string({local_var});");
699            }
700        }
701        for (handle_var, snake_type) in intermediate_handles.iter().rev() {
702            if snake_type == "free_string" {
703                let _ = writeln!(out, "    {prefix}_free_string({handle_var});");
704            } else {
705                let _ = writeln!(out, "    {prefix}_{snake_type}_free({handle_var});");
706            }
707        }
708        let result_type_snake = result_type_name.to_snake_case();
709        let _ = writeln!(out, "    {prefix}_{result_type_snake}_free({result_var});");
710        for (_, var_name) in &request_handle_vars {
711            let req_snake = var_name.strip_suffix("_handle").unwrap_or(var_name);
712            let _ = writeln!(out, "    {prefix}_{req_snake}_free({var_name});");
713        }
714        let _ = writeln!(out, "    {prefix}_default_client_free(client);");
715        let _ = writeln!(out, "}}");
716        return;
717    }
718
719    // Raw C result type path: functions returning a primitive C type (char*, int32_t,
720    // uintptr_t) rather than an opaque handle pointer.
721    if let Some(raw_type) = raw_c_result_type {
722        // Build argument string. Void-arg functions pass nothing.
723        let args_str = if args.is_empty() {
724            String::new()
725        } else {
726            let parts: Vec<String> = args
727                .iter()
728                .filter_map(|arg| {
729                    let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
730                    let val = fixture.input.get(field);
731                    match val {
732                        None if arg.optional => Some("NULL".to_string()),
733                        None => None,
734                        Some(v) if v.is_null() && arg.optional => Some("NULL".to_string()),
735                        Some(v) => Some(json_to_c(v)),
736                    }
737                })
738                .collect();
739            parts.join(", ")
740        };
741
742        // Declare result variable.
743        let _ = writeln!(out, "    {raw_type} {result_var} = {function_name}({args_str});");
744
745        // not_error assertion.
746        let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
747        if has_not_error {
748            match raw_type {
749                "char*" if !result_is_option => {
750                    let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
751                }
752                "int32_t" => {
753                    let _ = writeln!(out, "    assert({result_var} >= 0 && \"expected call to succeed\");");
754                }
755                "uintptr_t" => {
756                    let _ = writeln!(
757                        out,
758                        "    assert({prefix}_last_error_code() == 0 && \"expected call to succeed\");"
759                    );
760                }
761                _ => {}
762            }
763        }
764
765        // Other assertions.
766        for assertion in &fixture.assertions {
767            match assertion.assertion_type.as_str() {
768                "not_error" | "error" => {} // handled above / not applicable
769                "not_empty" => {
770                    let _ = writeln!(
771                        out,
772                        "    assert({result_var} != NULL && strlen({result_var}) > 0 && \"expected non-empty value\");"
773                    );
774                }
775                "is_empty" => {
776                    if result_is_option && raw_type == "char*" {
777                        let _ = writeln!(
778                            out,
779                            "    assert({result_var} == NULL && \"expected empty/null value\");"
780                        );
781                    } else {
782                        let _ = writeln!(
783                            out,
784                            "    assert(strlen({result_var}) == 0 && \"expected empty value\");"
785                        );
786                    }
787                }
788                "count_min" => {
789                    if let Some(val) = &assertion.value {
790                        if let Some(n) = val.as_u64() {
791                            match raw_type {
792                                "char*" => {
793                                    let _ = writeln!(out, "    {{");
794                                    let _ = writeln!(
795                                        out,
796                                        "        assert({result_var} != NULL && \"expected non-null JSON array\");"
797                                    );
798                                    let _ =
799                                        writeln!(out, "        int elem_count = alef_json_array_count({result_var});");
800                                    let _ = writeln!(
801                                        out,
802                                        "        assert(elem_count >= {n} && \"expected at least {n} elements\");"
803                                    );
804                                    let _ = writeln!(out, "    }}");
805                                }
806                                _ => {
807                                    let _ = writeln!(
808                                        out,
809                                        "    assert((size_t){result_var} >= {n} && \"expected at least {n} elements\");"
810                                    );
811                                }
812                            }
813                        }
814                    }
815                }
816                "greater_than_or_equal" => {
817                    if let Some(val) = &assertion.value {
818                        let c_val = json_to_c(val);
819                        let _ = writeln!(
820                            out,
821                            "    assert({result_var} >= {c_val} && \"expected greater than or equal\");"
822                        );
823                    }
824                }
825                "contains" => {
826                    if let Some(val) = &assertion.value {
827                        let c_val = json_to_c(val);
828                        let _ = writeln!(
829                            out,
830                            "    assert(strstr({result_var}, {c_val}) != NULL && \"expected to contain substring\");"
831                        );
832                    }
833                }
834                "contains_all" => {
835                    if let Some(values) = &assertion.values {
836                        for val in values {
837                            let c_val = json_to_c(val);
838                            let _ = writeln!(
839                                out,
840                                "    assert(strstr({result_var}, {c_val}) != NULL && \"expected to contain substring\");"
841                            );
842                        }
843                    }
844                }
845                "equals" => {
846                    if let Some(val) = &assertion.value {
847                        let c_val = json_to_c(val);
848                        if val.is_string() {
849                            let _ = writeln!(
850                                out,
851                                "    assert({result_var} != NULL && str_trim_eq({result_var}, {c_val}) == 0 && \"equals assertion failed\");"
852                            );
853                        } else {
854                            let _ = writeln!(
855                                out,
856                                "    assert({result_var} == {c_val} && \"equals assertion failed\");"
857                            );
858                        }
859                    }
860                }
861                "not_contains" => {
862                    if let Some(val) = &assertion.value {
863                        let c_val = json_to_c(val);
864                        let _ = writeln!(
865                            out,
866                            "    assert(strstr({result_var}, {c_val}) == NULL && \"expected NOT to contain substring\");"
867                        );
868                    }
869                }
870                "starts_with" => {
871                    if let Some(val) = &assertion.value {
872                        let c_val = json_to_c(val);
873                        let _ = writeln!(
874                            out,
875                            "    assert(strncmp({result_var}, {c_val}, strlen({c_val})) == 0 && \"expected to start with\");"
876                        );
877                    }
878                }
879                "is_true" => {
880                    let _ = writeln!(out, "    assert({result_var});");
881                }
882                "is_false" => {
883                    let _ = writeln!(out, "    assert(!{result_var});");
884                }
885                other => {
886                    panic!("C e2e raw-result generator: unsupported assertion type: {other}");
887                }
888            }
889        }
890
891        // Free char* results.
892        if raw_type == "char*" {
893            let free_fn = c_free_fn
894                .map(|s| s.to_string())
895                .unwrap_or_else(|| format!("{prefix}_free_string"));
896            if result_is_option {
897                let _ = writeln!(out, "    if ({result_var} != NULL) {{ {free_fn}({result_var}); }}");
898            } else {
899                let _ = writeln!(out, "    {free_fn}({result_var});");
900            }
901        }
902
903        let _ = writeln!(out, "}}");
904        return;
905    }
906
907    // Legacy (non-client) path: call the function directly.
908    // Used for libraries like html-to-markdown that expose standalone FFI functions.
909
910    // Use the function name directly — the override already includes the prefix
911    // (e.g. "htm_convert"), so we must NOT prepend it again.
912    let prefixed_fn = function_name.to_string();
913
914    // For json_object args, emit a from_json call to construct the options handle.
915    let mut has_options_handle = false;
916    for arg in args {
917        if arg.arg_type == "json_object" {
918            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
919            if let Some(val) = fixture.input.get(field) {
920                if !val.is_null() {
921                    // Fixture keys are camelCase; the FFI htm_conversion_options_from_json
922                    // deserializes into the Rust ConversionOptions type which uses default
923                    // serde (snake_case). Normalize keys before serializing.
924                    let normalized = super::normalize_json_keys_to_snake_case(val);
925                    let json_str = serde_json::to_string(&normalized).unwrap_or_default();
926                    let escaped = escape_c(&json_str);
927                    let upper = prefix.to_uppercase();
928                    let options_type_pascal = options_type_name;
929                    let options_type_snake = options_type_name.to_snake_case();
930                    let _ = writeln!(
931                        out,
932                        "    {upper}{options_type_pascal}* options_handle = {prefix}_{options_type_snake}_from_json(\"{escaped}\");"
933                    );
934                    has_options_handle = true;
935                }
936            }
937        }
938    }
939
940    let args_str = build_args_string_c(&fixture.input, args, has_options_handle);
941
942    if expects_error {
943        let _ = writeln!(
944            out,
945            "    {prefix_upper}{result_type_name}* {result_var} = {prefixed_fn}({args_str});"
946        );
947        if has_options_handle {
948            let options_type_snake = options_type_name.to_snake_case();
949            let _ = writeln!(out, "    {prefix}_{options_type_snake}_free(options_handle);");
950        }
951        let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
952        let _ = writeln!(out, "}}");
953        return;
954    }
955
956    // The FFI returns an opaque handle; extract the content string from it.
957    let _ = writeln!(
958        out,
959        "    {prefix_upper}{result_type_name}* {result_var} = {prefixed_fn}({args_str});"
960    );
961    let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
962
963    // Collect fields accessed by assertions so we can emit accessor calls.
964    // C FFI uses the opaque handle pattern: {prefix}_conversion_result_{field}(handle).
965    // For nested paths we generate chained FFI accessor calls using the type
966    // chain from `fields_c_types`.
967    // Each entry: (fixture_field, local_var, from_json_extract).
968    // `from_json_extract` is true when the variable was extracted from a JSON
969    // map via alef_json_get_string and needs free() instead of {prefix}_free_string().
970    let mut accessed_fields: Vec<(String, String, bool)> = Vec::new();
971    // Track intermediate handles emitted so we can free them and avoid duplicates.
972    // Each entry: (handle_var_name, snake_type_name) — freed in reverse order.
973    let mut intermediate_handles: Vec<(String, String)> = Vec::new();
974
975    for assertion in &fixture.assertions {
976        if let Some(f) = &assertion.field {
977            if !f.is_empty() && !accessed_fields.iter().any(|(k, _, _)| k == f) {
978                let resolved = field_resolver.resolve(f);
979                let local_var = f.replace(['.', '['], "_").replace(']', "");
980                let has_map_access = resolved.contains('[');
981
982                if resolved.contains('.') {
983                    emit_nested_accessor(
984                        out,
985                        prefix,
986                        resolved,
987                        &local_var,
988                        result_var,
989                        fields_c_types,
990                        &mut intermediate_handles,
991                        result_type_name,
992                    );
993                } else {
994                    let result_type_snake = result_type_name.to_snake_case();
995                    let accessor_fn = format!("{prefix}_{result_type_snake}_{resolved}");
996                    let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({result_var});");
997                }
998                accessed_fields.push((f.clone(), local_var.clone(), has_map_access));
999            }
1000        }
1001    }
1002
1003    for assertion in &fixture.assertions {
1004        render_assertion(out, assertion, result_var, prefix, field_resolver, &accessed_fields);
1005    }
1006
1007    // Free extracted leaf strings.
1008    for (_f, local_var, from_json) in &accessed_fields {
1009        if *from_json {
1010            let _ = writeln!(out, "    free({local_var});");
1011        } else {
1012            let _ = writeln!(out, "    {prefix}_free_string({local_var});");
1013        }
1014    }
1015    // Free intermediate handles in reverse order.
1016    for (handle_var, snake_type) in intermediate_handles.iter().rev() {
1017        if snake_type == "free_string" {
1018            // free_string handles are freed with the free_string function directly.
1019            let _ = writeln!(out, "    {prefix}_free_string({handle_var});");
1020        } else {
1021            let _ = writeln!(out, "    {prefix}_{snake_type}_free({handle_var});");
1022        }
1023    }
1024    if has_options_handle {
1025        let options_type_snake = options_type_name.to_snake_case();
1026        let _ = writeln!(out, "    {prefix}_{options_type_snake}_free(options_handle);");
1027    }
1028    let result_type_snake = result_type_name.to_snake_case();
1029    let _ = writeln!(out, "    {prefix}_{result_type_snake}_free({result_var});");
1030    let _ = writeln!(out, "}}");
1031}
1032
1033/// Emit chained FFI accessor calls for a nested resolved field path.
1034///
1035/// For a path like `metadata.document.title`, this generates:
1036/// ```c
1037/// HTMHtmlMetadata* metadata_handle = htm_conversion_result_metadata(result);
1038/// assert(metadata_handle != NULL);
1039/// HTMDocumentMetadata* doc_handle = htm_html_metadata_document(metadata_handle);
1040/// assert(doc_handle != NULL);
1041/// char* metadata_title = htm_document_metadata_title(doc_handle);
1042/// ```
1043///
1044/// The type chain is looked up from `fields_c_types` which maps
1045/// `"{parent_snake_type}.{field}"` -> `"PascalCaseType"`.
1046#[allow(clippy::too_many_arguments)]
1047fn emit_nested_accessor(
1048    out: &mut String,
1049    prefix: &str,
1050    resolved: &str,
1051    local_var: &str,
1052    result_var: &str,
1053    fields_c_types: &HashMap<String, String>,
1054    intermediate_handles: &mut Vec<(String, String)>,
1055    result_type_name: &str,
1056) {
1057    let segments: Vec<&str> = resolved.split('.').collect();
1058    let prefix_upper = prefix.to_uppercase();
1059
1060    // Walk the path, starting from the root result type.
1061    let mut current_snake_type = result_type_name.to_snake_case();
1062    let mut current_handle = result_var.to_string();
1063
1064    for (i, segment) in segments.iter().enumerate() {
1065        let is_leaf = i + 1 == segments.len();
1066
1067        // Check for map access: "field[key]"
1068        if let Some(bracket_pos) = segment.find('[') {
1069            let field_name = &segment[..bracket_pos];
1070            let key = segment[bracket_pos + 1..].trim_end_matches(']');
1071            let field_snake = field_name.to_snake_case();
1072            let accessor_fn = format!("{prefix}_{current_snake_type}_{field_snake}");
1073
1074            // The map accessor returns a char* (JSON object string).
1075            // Use alef_json_get_string to extract the key value.
1076            let json_var = format!("{field_snake}_json");
1077            if !intermediate_handles.iter().any(|(h, _)| h == &json_var) {
1078                let _ = writeln!(out, "    char* {json_var} = {accessor_fn}({current_handle});");
1079                let _ = writeln!(out, "    assert({json_var} != NULL);");
1080                // Track for freeing — use prefix_free_string since it's a char*.
1081                intermediate_handles.push((json_var.clone(), "free_string".to_string()));
1082            }
1083            // Extract the key from the JSON map.
1084            let _ = writeln!(
1085                out,
1086                "    char* {local_var} = alef_json_get_string({json_var}, \"{key}\");"
1087            );
1088            return; // Map access is always the leaf.
1089        }
1090
1091        let seg_snake = segment.to_snake_case();
1092        let accessor_fn = format!("{prefix}_{current_snake_type}_{seg_snake}");
1093
1094        if is_leaf {
1095            // Leaf field returns char* — assign to the local variable.
1096            let _ = writeln!(out, "    char* {local_var} = {accessor_fn}({current_handle});");
1097        } else {
1098            // Intermediate field returns an opaque handle.
1099            let lookup_key = format!("{current_snake_type}.{seg_snake}");
1100            let return_type_pascal = match fields_c_types.get(&lookup_key) {
1101                Some(t) => t.clone(),
1102                None => {
1103                    // Fallback: derive PascalCase from the segment name itself.
1104                    segment.to_pascal_case()
1105                }
1106            };
1107            let return_snake = return_type_pascal.to_snake_case();
1108            let handle_var = format!("{seg_snake}_handle");
1109
1110            // Only emit the handle if we haven't already (multiple fields may
1111            // share the same intermediate path prefix).
1112            if !intermediate_handles.iter().any(|(h, _)| h == &handle_var) {
1113                let _ = writeln!(
1114                    out,
1115                    "    {prefix_upper}{return_type_pascal}* {handle_var} = \
1116                     {accessor_fn}({current_handle});"
1117                );
1118                let _ = writeln!(out, "    assert({handle_var} != NULL);");
1119                intermediate_handles.push((handle_var.clone(), return_snake.clone()));
1120            }
1121
1122            current_snake_type = return_snake;
1123            current_handle = handle_var;
1124        }
1125    }
1126}
1127
1128/// Build the C argument string for the function call.
1129/// When `has_options_handle` is true, json_object args are replaced with
1130/// the `options_handle` pointer (which was constructed via `from_json`).
1131fn build_args_string_c(
1132    input: &serde_json::Value,
1133    args: &[crate::config::ArgMapping],
1134    has_options_handle: bool,
1135) -> String {
1136    if args.is_empty() {
1137        return json_to_c(input);
1138    }
1139
1140    let parts: Vec<String> = args
1141        .iter()
1142        .filter_map(|arg| {
1143            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1144            let val = input.get(field);
1145            match val {
1146                // Field missing entirely and optional → pass NULL.
1147                None if arg.optional => Some("NULL".to_string()),
1148                // Field missing and required → skip (caller error, but don't crash).
1149                None => None,
1150                // Explicit null on optional arg → pass NULL.
1151                Some(v) if v.is_null() && arg.optional => Some("NULL".to_string()),
1152                Some(v) => {
1153                    // For json_object args, use the options_handle pointer
1154                    // instead of the raw JSON string.
1155                    if arg.arg_type == "json_object" && has_options_handle && !v.is_null() {
1156                        Some("options_handle".to_string())
1157                    } else {
1158                        Some(json_to_c(v))
1159                    }
1160                }
1161            }
1162        })
1163        .collect();
1164
1165    parts.join(", ")
1166}
1167
1168fn render_assertion(
1169    out: &mut String,
1170    assertion: &Assertion,
1171    result_var: &str,
1172    ffi_prefix: &str,
1173    _field_resolver: &FieldResolver,
1174    accessed_fields: &[(String, String, bool)],
1175) {
1176    // Skip assertions on fields that don't exist on the result type.
1177    if let Some(f) = &assertion.field {
1178        if !f.is_empty() && !_field_resolver.is_valid_for_result(f) {
1179            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1180            return;
1181        }
1182    }
1183
1184    let field_expr = match &assertion.field {
1185        Some(f) if !f.is_empty() => {
1186            // Use the local variable extracted from the opaque handle.
1187            accessed_fields
1188                .iter()
1189                .find(|(k, _, _)| k == f)
1190                .map(|(_, local, _)| local.clone())
1191                .unwrap_or_else(|| result_var.to_string())
1192        }
1193        _ => result_var.to_string(),
1194    };
1195
1196    match assertion.assertion_type.as_str() {
1197        "equals" => {
1198            if let Some(expected) = &assertion.value {
1199                let c_val = json_to_c(expected);
1200                if expected.is_string() {
1201                    // Use str_trim_eq for string comparisons to handle trailing whitespace.
1202                    let _ = writeln!(
1203                        out,
1204                        "    assert(str_trim_eq({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
1205                    );
1206                } else {
1207                    let _ = writeln!(
1208                        out,
1209                        "    assert(strcmp({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
1210                    );
1211                }
1212            }
1213        }
1214        "contains" => {
1215            if let Some(expected) = &assertion.value {
1216                let c_val = json_to_c(expected);
1217                let _ = writeln!(
1218                    out,
1219                    "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
1220                );
1221            }
1222        }
1223        "contains_all" => {
1224            if let Some(values) = &assertion.values {
1225                for val in values {
1226                    let c_val = json_to_c(val);
1227                    let _ = writeln!(
1228                        out,
1229                        "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
1230                    );
1231                }
1232            }
1233        }
1234        "not_contains" => {
1235            if let Some(expected) = &assertion.value {
1236                let c_val = json_to_c(expected);
1237                let _ = writeln!(
1238                    out,
1239                    "    assert(strstr({field_expr}, {c_val}) == NULL && \"expected NOT to contain substring\");"
1240                );
1241            }
1242        }
1243        "not_empty" => {
1244            let _ = writeln!(
1245                out,
1246                "    assert({field_expr} != NULL && strlen({field_expr}) > 0 && \"expected non-empty value\");"
1247            );
1248        }
1249        "is_empty" => {
1250            let _ = writeln!(
1251                out,
1252                "    assert(strlen({field_expr}) == 0 && \"expected empty value\");"
1253            );
1254        }
1255        "contains_any" => {
1256            if let Some(values) = &assertion.values {
1257                let _ = writeln!(out, "    {{");
1258                let _ = writeln!(out, "        int found = 0;");
1259                for val in values {
1260                    let c_val = json_to_c(val);
1261                    let _ = writeln!(
1262                        out,
1263                        "        if (strstr({field_expr}, {c_val}) != NULL) {{ found = 1; }}"
1264                    );
1265                }
1266                let _ = writeln!(
1267                    out,
1268                    "        assert(found && \"expected to contain at least one of the specified values\");"
1269                );
1270                let _ = writeln!(out, "    }}");
1271            }
1272        }
1273        "greater_than" => {
1274            if let Some(val) = &assertion.value {
1275                let c_val = json_to_c(val);
1276                let _ = writeln!(out, "    assert({field_expr} > {c_val} && \"expected greater than\");");
1277            }
1278        }
1279        "less_than" => {
1280            if let Some(val) = &assertion.value {
1281                let c_val = json_to_c(val);
1282                let _ = writeln!(out, "    assert({field_expr} < {c_val} && \"expected less than\");");
1283            }
1284        }
1285        "greater_than_or_equal" => {
1286            if let Some(val) = &assertion.value {
1287                let c_val = json_to_c(val);
1288                let _ = writeln!(
1289                    out,
1290                    "    assert({field_expr} >= {c_val} && \"expected greater than or equal\");"
1291                );
1292            }
1293        }
1294        "less_than_or_equal" => {
1295            if let Some(val) = &assertion.value {
1296                let c_val = json_to_c(val);
1297                let _ = writeln!(
1298                    out,
1299                    "    assert({field_expr} <= {c_val} && \"expected less than or equal\");"
1300                );
1301            }
1302        }
1303        "starts_with" => {
1304            if let Some(expected) = &assertion.value {
1305                let c_val = json_to_c(expected);
1306                let _ = writeln!(
1307                    out,
1308                    "    assert(strncmp({field_expr}, {c_val}, strlen({c_val})) == 0 && \"expected to start with\");"
1309                );
1310            }
1311        }
1312        "ends_with" => {
1313            if let Some(expected) = &assertion.value {
1314                let c_val = json_to_c(expected);
1315                let _ = writeln!(out, "    assert(strlen({field_expr}) >= strlen({c_val}) && ");
1316                let _ = writeln!(
1317                    out,
1318                    "           strcmp({field_expr} + strlen({field_expr}) - strlen({c_val}), {c_val}) == 0 && \"expected to end with\");"
1319                );
1320            }
1321        }
1322        "min_length" => {
1323            if let Some(val) = &assertion.value {
1324                if let Some(n) = val.as_u64() {
1325                    let _ = writeln!(
1326                        out,
1327                        "    assert(strlen({field_expr}) >= {n} && \"expected minimum length\");"
1328                    );
1329                }
1330            }
1331        }
1332        "max_length" => {
1333            if let Some(val) = &assertion.value {
1334                if let Some(n) = val.as_u64() {
1335                    let _ = writeln!(
1336                        out,
1337                        "    assert(strlen({field_expr}) <= {n} && \"expected maximum length\");"
1338                    );
1339                }
1340            }
1341        }
1342        "count_min" => {
1343            if let Some(val) = &assertion.value {
1344                if let Some(n) = val.as_u64() {
1345                    let _ = writeln!(out, "    {{");
1346                    let _ = writeln!(out, "        /* count_min: count top-level JSON array elements */");
1347                    let _ = writeln!(
1348                        out,
1349                        "        assert({field_expr} != NULL && \"expected non-null collection JSON\");"
1350                    );
1351                    let _ = writeln!(out, "        int elem_count = alef_json_array_count({field_expr});");
1352                    let _ = writeln!(
1353                        out,
1354                        "        assert(elem_count >= {n} && \"expected at least {n} elements\");"
1355                    );
1356                    let _ = writeln!(out, "    }}");
1357                }
1358            }
1359        }
1360        "count_equals" => {
1361            if let Some(val) = &assertion.value {
1362                if let Some(n) = val.as_u64() {
1363                    let _ = writeln!(out, "    {{");
1364                    let _ = writeln!(out, "        /* count_equals: count elements in array */");
1365                    let _ = writeln!(
1366                        out,
1367                        "        assert({field_expr} != NULL && \"expected non-null collection JSON\");"
1368                    );
1369                    let _ = writeln!(out, "        int elem_count = alef_json_array_count({field_expr});");
1370                    let _ = writeln!(out, "        assert(elem_count == {n} && \"expected {n} elements\");");
1371                    let _ = writeln!(out, "    }}");
1372                }
1373            }
1374        }
1375        "is_true" => {
1376            let _ = writeln!(out, "    assert({field_expr});");
1377        }
1378        "is_false" => {
1379            let _ = writeln!(out, "    assert(!{field_expr});");
1380        }
1381        "method_result" => {
1382            if let Some(method_name) = &assertion.method {
1383                render_method_result_assertion(
1384                    out,
1385                    result_var,
1386                    ffi_prefix,
1387                    method_name,
1388                    assertion.args.as_ref(),
1389                    assertion.return_type.as_deref(),
1390                    assertion.check.as_deref().unwrap_or("is_true"),
1391                    assertion.value.as_ref(),
1392                );
1393            } else {
1394                panic!("C e2e generator: method_result assertion missing 'method' field");
1395            }
1396        }
1397        "matches_regex" => {
1398            if let Some(expected) = &assertion.value {
1399                let c_val = json_to_c(expected);
1400                let _ = writeln!(out, "    {{");
1401                let _ = writeln!(out, "        regex_t _re;");
1402                let _ = writeln!(
1403                    out,
1404                    "        assert(regcomp(&_re, {c_val}, REG_EXTENDED) == 0 && \"regex compile failed\");"
1405                );
1406                let _ = writeln!(
1407                    out,
1408                    "        assert(regexec(&_re, {field_expr}, 0, NULL, 0) == 0 && \"expected value to match regex\");"
1409                );
1410                let _ = writeln!(out, "        regfree(&_re);");
1411                let _ = writeln!(out, "    }}");
1412            }
1413        }
1414        "not_error" => {
1415            // Already handled — the NULL check above covers this.
1416        }
1417        "error" => {
1418            // Handled at the test function level.
1419        }
1420        other => {
1421            panic!("C e2e generator: unsupported assertion type: {other}");
1422        }
1423    }
1424}
1425
1426/// Render a `method_result` assertion in C.
1427///
1428/// Dispatches generically using `{ffi_prefix}_{method_name}` for the FFI call.
1429/// The `return_type` fixture field controls how the return value is handled:
1430/// - `"string"` — the method returns a heap-allocated `char*`; the generator
1431///   emits a scoped block that asserts, then calls `free()`.
1432/// - absent/other — treated as a primitive integer (or pointer-as-bool); the
1433///   assertion is emitted inline without any heap management.
1434#[allow(clippy::too_many_arguments)]
1435fn render_method_result_assertion(
1436    out: &mut String,
1437    result_var: &str,
1438    ffi_prefix: &str,
1439    method_name: &str,
1440    args: Option<&serde_json::Value>,
1441    return_type: Option<&str>,
1442    check: &str,
1443    value: Option<&serde_json::Value>,
1444) {
1445    let call_expr = build_c_method_call(result_var, ffi_prefix, method_name, args);
1446
1447    if return_type == Some("string") {
1448        // Heap-allocated char* return: emit a scoped block, assert, then free.
1449        let _ = writeln!(out, "    {{");
1450        let _ = writeln!(out, "        char* _method_result = {call_expr};");
1451        if check == "is_error" {
1452            let _ = writeln!(
1453                out,
1454                "        assert(_method_result == NULL && \"expected method to return error\");"
1455            );
1456            let _ = writeln!(out, "    }}");
1457            return;
1458        }
1459        let _ = writeln!(
1460            out,
1461            "        assert(_method_result != NULL && \"method_result returned NULL\");"
1462        );
1463        match check {
1464            "contains" => {
1465                if let Some(val) = value {
1466                    let c_val = json_to_c(val);
1467                    let _ = writeln!(
1468                        out,
1469                        "        assert(strstr(_method_result, {c_val}) != NULL && \"method_result contains assertion failed\");"
1470                    );
1471                }
1472            }
1473            "equals" => {
1474                if let Some(val) = value {
1475                    let c_val = json_to_c(val);
1476                    let _ = writeln!(
1477                        out,
1478                        "        assert(str_trim_eq(_method_result, {c_val}) == 0 && \"method_result equals assertion failed\");"
1479                    );
1480                }
1481            }
1482            "is_true" => {
1483                let _ = writeln!(
1484                    out,
1485                    "        assert(_method_result != NULL && strlen(_method_result) > 0 && \"method_result is_true assertion failed\");"
1486                );
1487            }
1488            "count_min" => {
1489                if let Some(val) = value {
1490                    let n = val.as_u64().unwrap_or(0);
1491                    let _ = writeln!(out, "        int _elem_count = alef_json_array_count(_method_result);");
1492                    let _ = writeln!(
1493                        out,
1494                        "        assert(_elem_count >= {n} && \"method_result count_min assertion failed\");"
1495                    );
1496                }
1497            }
1498            other_check => {
1499                panic!("C e2e generator: unsupported method_result check type for string return: {other_check}");
1500            }
1501        }
1502        let _ = writeln!(out, "        free(_method_result);");
1503        let _ = writeln!(out, "    }}");
1504        return;
1505    }
1506
1507    // Primitive (integer / pointer-as-bool) return: inline assert, no heap management.
1508    match check {
1509        "equals" => {
1510            if let Some(val) = value {
1511                let c_val = json_to_c(val);
1512                let _ = writeln!(
1513                    out,
1514                    "    assert({call_expr} == {c_val} && \"method_result equals assertion failed\");"
1515                );
1516            }
1517        }
1518        "is_true" => {
1519            let _ = writeln!(
1520                out,
1521                "    assert({call_expr} && \"method_result is_true assertion failed\");"
1522            );
1523        }
1524        "is_false" => {
1525            let _ = writeln!(
1526                out,
1527                "    assert(!{call_expr} && \"method_result is_false assertion failed\");"
1528            );
1529        }
1530        "greater_than_or_equal" => {
1531            if let Some(val) = value {
1532                let n = val.as_u64().unwrap_or(0);
1533                let _ = writeln!(
1534                    out,
1535                    "    assert({call_expr} >= {n} && \"method_result >= {n} assertion failed\");"
1536                );
1537            }
1538        }
1539        "count_min" => {
1540            if let Some(val) = value {
1541                let n = val.as_u64().unwrap_or(0);
1542                let _ = writeln!(
1543                    out,
1544                    "    assert({call_expr} >= {n} && \"method_result count_min assertion failed\");"
1545                );
1546            }
1547        }
1548        other_check => {
1549            panic!("C e2e generator: unsupported method_result check type: {other_check}");
1550        }
1551    }
1552}
1553
1554/// Build a C call expression for a `method_result` assertion.
1555///
1556/// Uses generic dispatch: `{ffi_prefix}_{method_name}(result_var, args...)`.
1557/// Args from the fixture JSON object are emitted as positional C arguments in
1558/// insertion order, using best-effort type conversion (strings → C string literals,
1559/// numbers and booleans → verbatim literals).
1560fn build_c_method_call(
1561    result_var: &str,
1562    ffi_prefix: &str,
1563    method_name: &str,
1564    args: Option<&serde_json::Value>,
1565) -> String {
1566    let extra_args = if let Some(args_val) = args {
1567        args_val
1568            .as_object()
1569            .map(|obj| {
1570                obj.values()
1571                    .map(|v| match v {
1572                        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
1573                        serde_json::Value::Bool(true) => "1".to_string(),
1574                        serde_json::Value::Bool(false) => "0".to_string(),
1575                        serde_json::Value::Number(n) => n.to_string(),
1576                        serde_json::Value::Null => "NULL".to_string(),
1577                        other => format!("\"{}\"", escape_c(&other.to_string())),
1578                    })
1579                    .collect::<Vec<_>>()
1580                    .join(", ")
1581            })
1582            .unwrap_or_default()
1583    } else {
1584        String::new()
1585    };
1586
1587    if extra_args.is_empty() {
1588        format!("{ffi_prefix}_{method_name}({result_var})")
1589    } else {
1590        format!("{ffi_prefix}_{method_name}({result_var}, {extra_args})")
1591    }
1592}
1593
1594/// Convert a `serde_json::Value` to a C literal string.
1595fn json_to_c(value: &serde_json::Value) -> String {
1596    match value {
1597        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
1598        serde_json::Value::Bool(true) => "1".to_string(),
1599        serde_json::Value::Bool(false) => "0".to_string(),
1600        serde_json::Value::Number(n) => n.to_string(),
1601        serde_json::Value::Null => "NULL".to_string(),
1602        other => format!("\"{}\"", escape_c(&other.to_string())),
1603    }
1604}