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