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