Skip to main content

alef_e2e/codegen/
c.rs

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