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        "not_error" => {
1168            // Already handled — the NULL check above covers this.
1169        }
1170        "error" => {
1171            // Handled at the test function level.
1172        }
1173        other => {
1174            panic!("C e2e generator: unsupported assertion type: {other}");
1175        }
1176    }
1177}
1178
1179/// Render a `method_result` assertion in C.
1180///
1181/// Emits a scoped block that calls the appropriate FFI function on `result_var`,
1182/// performs the requested check, and frees any heap-allocated return values.
1183fn render_method_result_assertion(
1184    out: &mut String,
1185    result_var: &str,
1186    method_name: &str,
1187    args: Option<&serde_json::Value>,
1188    check: &str,
1189    value: Option<&serde_json::Value>,
1190) {
1191    let call_expr = build_c_method_call(result_var, method_name, args);
1192
1193    match method_name {
1194        // Integer-returning methods: no heap allocation, inline assert.
1195        "has_error_nodes" | "error_count" | "tree_error_count" => match check {
1196            "equals" => {
1197                if let Some(val) = value {
1198                    let c_val = json_to_c(val);
1199                    let _ = writeln!(
1200                        out,
1201                        "    assert({call_expr} == {c_val} && \"method_result equals assertion failed\");"
1202                    );
1203                }
1204            }
1205            "is_true" => {
1206                let _ = writeln!(
1207                    out,
1208                    "    assert({call_expr} && \"method_result is_true assertion failed\");"
1209                );
1210            }
1211            "is_false" => {
1212                let _ = writeln!(
1213                    out,
1214                    "    assert(!{call_expr} && \"method_result is_false assertion failed\");"
1215                );
1216            }
1217            "greater_than_or_equal" => {
1218                if let Some(val) = value {
1219                    let n = val.as_u64().unwrap_or(0);
1220                    let _ = writeln!(
1221                        out,
1222                        "    assert({call_expr} >= {n} && \"method_result >= {n} assertion failed\");"
1223                    );
1224                }
1225            }
1226            other_check => {
1227                panic!("C e2e generator: unsupported method_result check type: {other_check}");
1228            }
1229        },
1230
1231        // root_child_count / named_children_count: allocate NodeInfo, get count, free NodeInfo.
1232        "root_child_count" | "named_children_count" => {
1233            let _ = writeln!(out, "    {{");
1234            let _ = writeln!(
1235                out,
1236                "        TS_PACKNodeInfo* _node_info = ts_pack_root_node_info({result_var});"
1237            );
1238            let _ = writeln!(
1239                out,
1240                "        assert(_node_info != NULL && \"root_node_info returned NULL\");"
1241            );
1242            let _ = writeln!(
1243                out,
1244                "        size_t _count = ts_pack_node_info_named_child_count(_node_info);"
1245            );
1246            let _ = writeln!(out, "        ts_pack_node_info_free(_node_info);");
1247            match check {
1248                "equals" => {
1249                    if let Some(val) = value {
1250                        let c_val = json_to_c(val);
1251                        let _ = writeln!(
1252                            out,
1253                            "        assert(_count == (size_t){c_val} && \"method_result equals assertion failed\");"
1254                        );
1255                    }
1256                }
1257                "greater_than_or_equal" => {
1258                    if let Some(val) = value {
1259                        let n = val.as_u64().unwrap_or(0);
1260                        let _ = writeln!(
1261                            out,
1262                            "        assert(_count >= {n} && \"method_result >= {n} assertion failed\");"
1263                        );
1264                    }
1265                }
1266                "is_true" => {
1267                    let _ = writeln!(
1268                        out,
1269                        "        assert(_count > 0 && \"method_result is_true assertion failed\");"
1270                    );
1271                }
1272                other_check => {
1273                    panic!("C e2e generator: unsupported method_result check type: {other_check}");
1274                }
1275            }
1276            let _ = writeln!(out, "    }}");
1277        }
1278
1279        // String-returning methods: heap-allocated char*, must free after assert.
1280        "tree_to_sexp" => {
1281            let _ = writeln!(out, "    {{");
1282            let _ = writeln!(out, "        char* _method_result = {call_expr};");
1283            let _ = writeln!(
1284                out,
1285                "        assert(_method_result != NULL && \"method_result returned NULL\");"
1286            );
1287            match check {
1288                "contains" => {
1289                    if let Some(val) = value {
1290                        let c_val = json_to_c(val);
1291                        let _ = writeln!(
1292                            out,
1293                            "        assert(strstr(_method_result, {c_val}) != NULL && \"method_result contains assertion failed\");"
1294                        );
1295                    }
1296                }
1297                "equals" => {
1298                    if let Some(val) = value {
1299                        let c_val = json_to_c(val);
1300                        let _ = writeln!(
1301                            out,
1302                            "        assert(str_trim_eq(_method_result, {c_val}) == 0 && \"method_result equals assertion failed\");"
1303                        );
1304                    }
1305                }
1306                "is_true" => {
1307                    let _ = writeln!(
1308                        out,
1309                        "        assert(_method_result != NULL && strlen(_method_result) > 0 && \"method_result is_true assertion failed\");"
1310                    );
1311                }
1312                other_check => {
1313                    panic!("C e2e generator: unsupported method_result check type: {other_check}");
1314                }
1315            }
1316            let _ = writeln!(out, "        ts_pack_free_string(_method_result);");
1317            let _ = writeln!(out, "    }}");
1318        }
1319
1320        // contains_node_type returns int32_t (boolean).
1321        "contains_node_type" => match check {
1322            "equals" => {
1323                if let Some(val) = value {
1324                    let c_val = json_to_c(val);
1325                    let _ = writeln!(
1326                        out,
1327                        "    assert({call_expr} == {c_val} && \"method_result equals assertion failed\");"
1328                    );
1329                }
1330            }
1331            "is_true" => {
1332                let _ = writeln!(
1333                    out,
1334                    "    assert({call_expr} && \"method_result is_true assertion failed\");"
1335                );
1336            }
1337            "is_false" => {
1338                let _ = writeln!(
1339                    out,
1340                    "    assert(!{call_expr} && \"method_result is_false assertion failed\");"
1341                );
1342            }
1343            other_check => {
1344                panic!("C e2e generator: unsupported method_result check type: {other_check}");
1345            }
1346        },
1347
1348        // find_nodes_by_type returns char* JSON array; count_min checks array length.
1349        "find_nodes_by_type" => {
1350            let _ = writeln!(out, "    {{");
1351            let _ = writeln!(out, "        char* _method_result = {call_expr};");
1352            let _ = writeln!(
1353                out,
1354                "        assert(_method_result != NULL && \"method_result returned NULL\");"
1355            );
1356            match check {
1357                "count_min" => {
1358                    if let Some(val) = value {
1359                        let n = val.as_u64().unwrap_or(0);
1360                        let _ = writeln!(out, "        int _elem_count = alef_json_array_count(_method_result);");
1361                        let _ = writeln!(
1362                            out,
1363                            "        assert(_elem_count >= {n} && \"method_result count_min assertion failed\");"
1364                        );
1365                    }
1366                }
1367                "is_true" => {
1368                    let _ = writeln!(
1369                        out,
1370                        "        assert(alef_json_array_count(_method_result) > 0 && \"method_result is_true assertion failed\");"
1371                    );
1372                }
1373                "is_false" => {
1374                    let _ = writeln!(
1375                        out,
1376                        "        assert(alef_json_array_count(_method_result) == 0 && \"method_result is_false assertion failed\");"
1377                    );
1378                }
1379                "equals" => {
1380                    if let Some(val) = value {
1381                        let n = val.as_u64().unwrap_or(0);
1382                        let _ = writeln!(out, "        int _elem_count = alef_json_array_count(_method_result);");
1383                        let _ = writeln!(
1384                            out,
1385                            "        assert(_elem_count == {n} && \"method_result equals assertion failed\");"
1386                        );
1387                    }
1388                }
1389                "greater_than_or_equal" => {
1390                    if let Some(val) = value {
1391                        let n = val.as_u64().unwrap_or(0);
1392                        let _ = writeln!(out, "        int _elem_count = alef_json_array_count(_method_result);");
1393                        let _ = writeln!(
1394                            out,
1395                            "        assert(_elem_count >= {n} && \"method_result greater_than_or_equal assertion failed\");"
1396                        );
1397                    }
1398                }
1399                "contains" => {
1400                    if let Some(val) = value {
1401                        let c_val = json_to_c(val);
1402                        let _ = writeln!(
1403                            out,
1404                            "        assert(strstr(_method_result, {c_val}) != NULL && \"method_result contains assertion failed\");"
1405                        );
1406                    }
1407                }
1408                other_check => {
1409                    panic!("C e2e generator: unsupported method_result check type: {other_check}");
1410                }
1411            }
1412            let _ = writeln!(out, "        ts_pack_free_string(_method_result);");
1413            let _ = writeln!(out, "    }}");
1414        }
1415
1416        // run_query returns char* JSON array.
1417        "run_query" => {
1418            let _ = writeln!(out, "    {{");
1419            let _ = writeln!(out, "        char* _method_result = {call_expr};");
1420            if check == "is_error" {
1421                let _ = writeln!(
1422                    out,
1423                    "        assert(_method_result == NULL && \"expected method to return error\");"
1424                );
1425                let _ = writeln!(out, "    }}");
1426                return;
1427            }
1428            let _ = writeln!(
1429                out,
1430                "        assert(_method_result != NULL && \"method_result returned NULL\");"
1431            );
1432            match check {
1433                "count_min" => {
1434                    if let Some(val) = value {
1435                        let n = val.as_u64().unwrap_or(0);
1436                        let _ = writeln!(out, "        int _elem_count = alef_json_array_count(_method_result);");
1437                        let _ = writeln!(
1438                            out,
1439                            "        assert(_elem_count >= {n} && \"method_result count_min assertion failed\");"
1440                        );
1441                    }
1442                }
1443                "is_true" => {
1444                    let _ = writeln!(
1445                        out,
1446                        "        assert(alef_json_array_count(_method_result) > 0 && \"method_result is_true assertion failed\");"
1447                    );
1448                }
1449                "contains" => {
1450                    if let Some(val) = value {
1451                        let c_val = json_to_c(val);
1452                        let _ = writeln!(
1453                            out,
1454                            "        assert(strstr(_method_result, {c_val}) != NULL && \"method_result contains assertion failed\");"
1455                        );
1456                    }
1457                }
1458                other_check => {
1459                    panic!("C e2e generator: unsupported method_result check type: {other_check}");
1460                }
1461            }
1462            let _ = writeln!(out, "        ts_pack_free_string(_method_result);");
1463            let _ = writeln!(out, "    }}");
1464        }
1465
1466        // root_node_type: get NodeInfo, serialize to JSON, extract "kind" field.
1467        "root_node_type" => {
1468            let _ = writeln!(out, "    {{");
1469            let _ = writeln!(
1470                out,
1471                "        TS_PACKNodeInfo* _node_info = ts_pack_root_node_info({result_var});"
1472            );
1473            let _ = writeln!(
1474                out,
1475                "        assert(_node_info != NULL && \"root_node_info returned NULL\");"
1476            );
1477            let _ = writeln!(out, "        char* _node_json = ts_pack_node_info_to_json(_node_info);");
1478            let _ = writeln!(
1479                out,
1480                "        assert(_node_json != NULL && \"node_info_to_json returned NULL\");"
1481            );
1482            let _ = writeln!(out, "        char* _kind = alef_json_get_string(_node_json, \"kind\");");
1483            let _ = writeln!(
1484                out,
1485                "        assert(_kind != NULL && \"kind field not found in NodeInfo JSON\");"
1486            );
1487            match check {
1488                "equals" => {
1489                    if let Some(val) = value {
1490                        let c_val = json_to_c(val);
1491                        let _ = writeln!(
1492                            out,
1493                            "        assert(strcmp(_kind, {c_val}) == 0 && \"method_result equals assertion failed\");"
1494                        );
1495                    }
1496                }
1497                "contains" => {
1498                    if let Some(val) = value {
1499                        let c_val = json_to_c(val);
1500                        let _ = writeln!(
1501                            out,
1502                            "        assert(strstr(_kind, {c_val}) != NULL && \"method_result contains assertion failed\");"
1503                        );
1504                    }
1505                }
1506                "is_true" => {
1507                    let _ = writeln!(
1508                        out,
1509                        "        assert(_kind != NULL && strlen(_kind) > 0 && \"method_result is_true assertion failed\");"
1510                    );
1511                }
1512                other_check => {
1513                    panic!("C e2e generator: unsupported method_result check type: {other_check}");
1514                }
1515            }
1516            let _ = writeln!(out, "        free(_kind);");
1517            let _ = writeln!(out, "        ts_pack_free_string(_node_json);");
1518            let _ = writeln!(out, "        ts_pack_node_info_free(_node_info);");
1519            let _ = writeln!(out, "    }}");
1520        }
1521
1522        other_method => {
1523            panic!("C e2e generator: unsupported method_result method: {other_method}");
1524        }
1525    }
1526}
1527
1528/// Build a C call expression for a `method_result` assertion on a tree-sitter Tree.
1529///
1530/// Maps well-known method names to the appropriate C FFI function calls.
1531/// For integer-returning methods, returns the full expression.
1532/// For pointer-returning methods, returns the expression (callers allocate a block).
1533fn build_c_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1534    match method_name {
1535        "root_child_count" => {
1536            // tree-sitter native: get root node child count via NodeInfo named_child_count.
1537            format!("ts_pack_node_info_named_child_count(ts_pack_root_node_info({result_var}))")
1538        }
1539        "has_error_nodes" => format!("ts_pack_tree_has_error_nodes({result_var})"),
1540        "error_count" | "tree_error_count" => format!("ts_pack_tree_error_count({result_var})"),
1541        "tree_to_sexp" => format!("ts_pack_tree_to_sexp({result_var})"),
1542        "named_children_count" => {
1543            format!("ts_pack_node_info_named_child_count(ts_pack_root_node_info({result_var}))")
1544        }
1545        "contains_node_type" => {
1546            let node_type = args
1547                .and_then(|a| a.get("node_type"))
1548                .and_then(|v| v.as_str())
1549                .unwrap_or("");
1550            format!("ts_pack_tree_contains_node_type({result_var}, \"{node_type}\")")
1551        }
1552        "find_nodes_by_type" => {
1553            let node_type = args
1554                .and_then(|a| a.get("node_type"))
1555                .and_then(|v| v.as_str())
1556                .unwrap_or("");
1557            format!("ts_pack_find_nodes_by_type({result_var}, \"{node_type}\")")
1558        }
1559        "run_query" => {
1560            let query_source = args
1561                .and_then(|a| a.get("query_source"))
1562                .and_then(|v| v.as_str())
1563                .unwrap_or("");
1564            let language = args
1565                .and_then(|a| a.get("language"))
1566                .and_then(|v| v.as_str())
1567                .unwrap_or("");
1568            // source and source_len are passed as NULL/0 since fixtures don't provide raw bytes here.
1569            format!("ts_pack_run_query({result_var}, \"{language}\", \"{query_source}\", NULL, 0)")
1570        }
1571        // root_node_type is handled separately in render_method_result_assertion.
1572        "root_node_type" => String::new(),
1573        other_method => format!("ts_pack_{other_method}({result_var})"),
1574    }
1575}
1576
1577/// Convert a `serde_json::Value` to a C literal string.
1578fn json_to_c(value: &serde_json::Value) -> String {
1579    match value {
1580        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
1581        serde_json::Value::Bool(true) => "1".to_string(),
1582        serde_json::Value::Bool(false) => "0".to_string(),
1583        serde_json::Value::Number(n) => n.to_string(),
1584        serde_json::Value::Null => "NULL".to_string(),
1585        other => format!("\"{}\"", escape_c(&other.to_string())),
1586    }
1587}