Skip to main content

alef_e2e/codegen/
c.rs

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