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, and a `test_runner.h` header.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_c, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16use super::E2eCodegen;
17
18/// C e2e code generator.
19pub struct CCodegen;
20
21impl E2eCodegen for CCodegen {
22    fn generate(
23        &self,
24        groups: &[FixtureGroup],
25        e2e_config: &E2eConfig,
26        _alef_config: &AlefConfig,
27    ) -> Result<Vec<GeneratedFile>> {
28        let lang = self.language_name();
29        let output_base = PathBuf::from(&e2e_config.output).join(lang);
30
31        let mut files = Vec::new();
32
33        // Resolve call config with overrides.
34        let call = &e2e_config.call;
35        let overrides = call.overrides.get(lang);
36        let function_name = overrides
37            .and_then(|o| o.function.as_ref())
38            .cloned()
39            .unwrap_or_else(|| call.function.clone());
40        let result_var = &call.result_var;
41        let prefix = overrides.and_then(|o| o.prefix.as_ref()).cloned().unwrap_or_default();
42        let header = overrides
43            .and_then(|o| o.header.as_ref())
44            .cloned()
45            .unwrap_or_else(|| format!("{}.h", call.module));
46
47        // Resolve package config.
48        let c_pkg = e2e_config.packages.get("c");
49        let include_path = c_pkg
50            .and_then(|p| p.path.as_ref())
51            .cloned()
52            .unwrap_or_else(|| "../../crates/ffi/include".to_string());
53        let lib_path = c_pkg
54            .and_then(|p| p.module.as_ref())
55            .cloned()
56            .unwrap_or_else(|| "../../target/release".to_string());
57        let lib_name = c_pkg
58            .and_then(|p| p.name.as_ref())
59            .cloned()
60            .unwrap_or_else(|| call.module.clone());
61
62        // Filter active groups (with non-skipped fixtures).
63        let active_groups: Vec<(&FixtureGroup, Vec<&Fixture>)> = groups
64            .iter()
65            .filter_map(|group| {
66                let active: Vec<&Fixture> = group
67                    .fixtures
68                    .iter()
69                    .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
70                    .collect();
71                if active.is_empty() { None } else { Some((group, active)) }
72            })
73            .collect();
74
75        // Generate Makefile.
76        let category_names: Vec<String> = active_groups
77            .iter()
78            .map(|(g, _)| sanitize_filename(&g.category))
79            .collect();
80        files.push(GeneratedFile {
81            path: output_base.join("Makefile"),
82            content: render_makefile(&category_names, &include_path, &lib_path, &lib_name),
83            generated_header: true,
84        });
85
86        // Generate test_runner.h.
87        files.push(GeneratedFile {
88            path: output_base.join("test_runner.h"),
89            content: render_test_runner_header(&active_groups),
90            generated_header: true,
91        });
92
93        // Generate main.c.
94        files.push(GeneratedFile {
95            path: output_base.join("main.c"),
96            content: render_main_c(&active_groups),
97            generated_header: true,
98        });
99
100        let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
101
102        // Generate per-category test files.
103        for (group, active) in &active_groups {
104            let filename = format!("test_{}.c", sanitize_filename(&group.category));
105            let content = render_test_file(
106                &group.category,
107                active,
108                &header,
109                &prefix,
110                &function_name,
111                result_var,
112                &e2e_config.call.args,
113                &field_resolver,
114            );
115            files.push(GeneratedFile {
116                path: output_base.join(filename),
117                content,
118                generated_header: true,
119            });
120        }
121
122        Ok(files)
123    }
124
125    fn language_name(&self) -> &'static str {
126        "c"
127    }
128}
129
130fn render_makefile(categories: &[String], include_path: &str, lib_path: &str, lib_name: &str) -> String {
131    let mut out = String::new();
132    let _ = writeln!(out, "CC = gcc");
133    let _ = writeln!(out, "CFLAGS = -Wall -Wextra -I{include_path}");
134    let _ = writeln!(out, "LDFLAGS = -L{lib_path} -l{lib_name}");
135    let _ = writeln!(out);
136
137    let src_files: Vec<String> = categories.iter().map(|c| format!("test_{c}.c")).collect();
138    let srcs = src_files.join(" ");
139
140    let _ = writeln!(out, "SRCS = main.c {srcs}");
141    let _ = writeln!(out, "TARGET = run_tests");
142    let _ = writeln!(out);
143    let _ = writeln!(out, ".PHONY: all clean test");
144    let _ = writeln!(out);
145    let _ = writeln!(out, "all: $(TARGET)");
146    let _ = writeln!(out);
147    let _ = writeln!(out, "$(TARGET): $(SRCS)");
148    let _ = writeln!(out, "\t$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)");
149    let _ = writeln!(out);
150    let _ = writeln!(out, "test: $(TARGET)");
151    let _ = writeln!(out, "\t./$(TARGET)");
152    let _ = writeln!(out);
153    let _ = writeln!(out, "clean:");
154    let _ = writeln!(out, "\trm -f $(TARGET)");
155    out
156}
157
158fn render_test_runner_header(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
159    let mut out = String::new();
160    let _ = writeln!(out, "#ifndef TEST_RUNNER_H");
161    let _ = writeln!(out, "#define TEST_RUNNER_H");
162    let _ = writeln!(out);
163
164    for (group, fixtures) in active_groups {
165        let _ = writeln!(out, "/* Tests for category: {} */", group.category);
166        for fixture in fixtures {
167            let fn_name = sanitize_ident(&fixture.id);
168            let _ = writeln!(out, "void test_{fn_name}(void);");
169        }
170        let _ = writeln!(out);
171    }
172
173    let _ = writeln!(out, "#endif /* TEST_RUNNER_H */");
174    out
175}
176
177fn render_main_c(active_groups: &[(&FixtureGroup, Vec<&Fixture>)]) -> String {
178    let mut out = String::new();
179    let _ = writeln!(out, "#include <stdio.h>");
180    let _ = writeln!(out, "#include \"test_runner.h\"");
181    let _ = writeln!(out);
182    let _ = writeln!(out, "int main(void) {{");
183    let _ = writeln!(out, "    int passed = 0;");
184    let _ = writeln!(out, "    int failed = 0;");
185    let _ = writeln!(out);
186
187    for (group, fixtures) in active_groups {
188        let _ = writeln!(out, "    /* Category: {} */", group.category);
189        for fixture in fixtures {
190            let fn_name = sanitize_ident(&fixture.id);
191            let _ = writeln!(out, "    printf(\"  Running test_{fn_name}...\");");
192            let _ = writeln!(out, "    test_{fn_name}();");
193            let _ = writeln!(out, "    printf(\" PASSED\\n\");");
194            let _ = writeln!(out, "    passed++;");
195        }
196        let _ = writeln!(out);
197    }
198
199    let _ = writeln!(
200        out,
201        "    printf(\"\\nResults: %d passed, %d failed\\n\", passed, failed);"
202    );
203    let _ = writeln!(out, "    return failed > 0 ? 1 : 0;");
204    let _ = writeln!(out, "}}");
205    out
206}
207
208fn render_test_file(
209    category: &str,
210    fixtures: &[&Fixture],
211    header: &str,
212    prefix: &str,
213    function_name: &str,
214    result_var: &str,
215    args: &[crate::config::ArgMapping],
216    field_resolver: &FieldResolver,
217) -> String {
218    let mut out = String::new();
219    let _ = writeln!(out, "/* E2e tests for category: {category} */");
220    let _ = writeln!(out);
221    let _ = writeln!(out, "#include <assert.h>");
222    let _ = writeln!(out, "#include <string.h>");
223    let _ = writeln!(out, "#include <stdio.h>");
224    let _ = writeln!(out, "#include <stdlib.h>");
225    let _ = writeln!(out, "#include \"{header}\"");
226    let _ = writeln!(out);
227
228    for (i, fixture) in fixtures.iter().enumerate() {
229        render_test_function(
230            &mut out,
231            fixture,
232            prefix,
233            function_name,
234            result_var,
235            args,
236            field_resolver,
237        );
238        if i + 1 < fixtures.len() {
239            let _ = writeln!(out);
240        }
241    }
242
243    out
244}
245
246fn render_test_function(
247    out: &mut String,
248    fixture: &Fixture,
249    prefix: &str,
250    function_name: &str,
251    result_var: &str,
252    args: &[crate::config::ArgMapping],
253    field_resolver: &FieldResolver,
254) {
255    let fn_name = sanitize_ident(&fixture.id);
256    let description = &fixture.description;
257
258    let prefixed_fn = if prefix.is_empty() {
259        function_name.to_string()
260    } else {
261        format!("{prefix}_{function_name}")
262    };
263
264    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
265
266    let args_str = build_args_string(&fixture.input, args);
267
268    let _ = writeln!(out, "void test_{fn_name}(void) {{");
269    let _ = writeln!(out, "    /* {description} */");
270
271    if expects_error {
272        let _ = writeln!(out, "    const char* {result_var} = {prefixed_fn}({args_str});");
273        let _ = writeln!(out, "    assert({result_var} == NULL && \"expected call to fail\");");
274        let _ = writeln!(out, "}}");
275        return;
276    }
277
278    let _ = writeln!(out, "    const char* {result_var} = {prefixed_fn}({args_str});");
279    let _ = writeln!(out, "    assert({result_var} != NULL && \"expected call to succeed\");");
280
281    for assertion in &fixture.assertions {
282        render_assertion(out, assertion, result_var, field_resolver);
283    }
284
285    let _ = writeln!(out, "}}");
286}
287
288fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
289    if args.is_empty() {
290        return json_to_c(input);
291    }
292
293    let parts: Vec<String> = args
294        .iter()
295        .filter_map(|arg| {
296            let val = input.get(&arg.field)?;
297            if val.is_null() && arg.optional {
298                return None;
299            }
300            Some(json_to_c(val))
301        })
302        .collect();
303
304    parts.join(", ")
305}
306
307fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
308    let field_expr = match &assertion.field {
309        Some(f) if !f.is_empty() => field_resolver.accessor(f, "c", result_var),
310        _ => result_var.to_string(),
311    };
312
313    match assertion.assertion_type.as_str() {
314        "equals" => {
315            if let Some(expected) = &assertion.value {
316                let c_val = json_to_c(expected);
317                let _ = writeln!(
318                    out,
319                    "    assert(strcmp({field_expr}, {c_val}) == 0 && \"equals assertion failed\");"
320                );
321            }
322        }
323        "contains" => {
324            if let Some(expected) = &assertion.value {
325                let c_val = json_to_c(expected);
326                let _ = writeln!(
327                    out,
328                    "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
329                );
330            }
331        }
332        "contains_all" => {
333            if let Some(values) = &assertion.values {
334                for val in values {
335                    let c_val = json_to_c(val);
336                    let _ = writeln!(
337                        out,
338                        "    assert(strstr({field_expr}, {c_val}) != NULL && \"expected to contain substring\");"
339                    );
340                }
341            }
342        }
343        "not_contains" => {
344            if let Some(expected) = &assertion.value {
345                let c_val = json_to_c(expected);
346                let _ = writeln!(
347                    out,
348                    "    assert(strstr({field_expr}, {c_val}) == NULL && \"expected NOT to contain substring\");"
349                );
350            }
351        }
352        "not_empty" => {
353            let _ = writeln!(
354                out,
355                "    assert(strlen({field_expr}) > 0 && \"expected non-empty value\");"
356            );
357        }
358        "is_empty" => {
359            let _ = writeln!(
360                out,
361                "    assert(strlen({field_expr}) == 0 && \"expected empty value\");"
362            );
363        }
364        "starts_with" => {
365            if let Some(expected) = &assertion.value {
366                let c_val = json_to_c(expected);
367                let _ = writeln!(
368                    out,
369                    "    assert(strncmp({field_expr}, {c_val}, strlen({c_val})) == 0 && \"expected to start with\");"
370                );
371            }
372        }
373        "ends_with" => {
374            if let Some(expected) = &assertion.value {
375                let c_val = json_to_c(expected);
376                let _ = writeln!(out, "    assert(strlen({field_expr}) >= strlen({c_val}) && ");
377                let _ = writeln!(
378                    out,
379                    "           strcmp({field_expr} + strlen({field_expr}) - strlen({c_val}), {c_val}) == 0 && \"expected to end with\");"
380                );
381            }
382        }
383        "min_length" => {
384            if let Some(val) = &assertion.value {
385                if let Some(n) = val.as_u64() {
386                    let _ = writeln!(
387                        out,
388                        "    assert(strlen({field_expr}) >= {n} && \"expected minimum length\");"
389                    );
390                }
391            }
392        }
393        "max_length" => {
394            if let Some(val) = &assertion.value {
395                if let Some(n) = val.as_u64() {
396                    let _ = writeln!(
397                        out,
398                        "    assert(strlen({field_expr}) <= {n} && \"expected maximum length\");"
399                    );
400                }
401            }
402        }
403        "not_error" => {
404            // Already handled — the NULL check above covers this.
405        }
406        "error" => {
407            // Handled at the test function level.
408        }
409        other => {
410            let _ = writeln!(out, "    /* TODO: unsupported assertion type: {other} */");
411        }
412    }
413}
414
415/// Convert a `serde_json::Value` to a C literal string.
416fn json_to_c(value: &serde_json::Value) -> String {
417    match value {
418        serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
419        serde_json::Value::Bool(true) => "1".to_string(),
420        serde_json::Value::Bool(false) => "0".to_string(),
421        serde_json::Value::Number(n) => n.to_string(),
422        serde_json::Value::Null => "NULL".to_string(),
423        other => format!("\"{}\"", escape_c(&other.to_string())),
424    }
425}