Skip to main content

alef_e2e/codegen/rust/
assertions.rs

1//! Assertion rendering for Rust e2e tests.
2
3use std::fmt::Write as FmtWrite;
4
5use crate::escape::escape_rust;
6use crate::field_access::FieldResolver;
7use crate::fixture::Assertion;
8
9use super::assertion_helpers::{
10    render_count_equals_assertion, render_count_min_assertion, render_equals_assertion, render_gte_assertion,
11    render_is_empty_assertion, render_method_result_assertion, render_not_empty_assertion,
12};
13use super::assertion_synthetic::{
14    numeric_literal, render_chunks_have_content, render_chunks_have_embeddings, render_embedding_dimensions,
15    render_embedding_quality, render_embeddings_assertion, render_keywords_assertion, render_keywords_count_assertion,
16    tree_field_access_expr, value_to_rust_string,
17};
18
19/// Render a single assertion into the test function body.
20#[allow(clippy::too_many_arguments)]
21pub fn render_assertion(
22    out: &mut String,
23    assertion: &Assertion,
24    result_var: &str,
25    module: &str,
26    dep_name: &str,
27    is_error_context: bool,
28    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
29    field_resolver: &FieldResolver,
30    result_is_tree: bool,
31    result_is_simple: bool,
32    result_is_vec: bool,
33    result_is_option: bool,
34    returns_result: bool,
35) {
36    // Vec<T> result: iterate per-element so each assertion checks every element.
37    // Field-path assertions become `for r in &{result} { <assert using r> }`.
38    // Length-style assertions on the Vec itself (no field path) operate on the
39    // Vec directly.
40    let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
41    if result_is_vec && has_field && !is_error_context {
42        let _ = writeln!(out, "    for r in &{result_var} {{");
43        render_assertion(
44            out,
45            assertion,
46            "r",
47            module,
48            dep_name,
49            is_error_context,
50            unwrapped_fields,
51            field_resolver,
52            result_is_tree,
53            result_is_simple,
54            false, // already inside loop
55            result_is_option,
56            returns_result,
57        );
58        let _ = writeln!(out, "    }}");
59        return;
60    }
61    // Option<T> result: map `is_empty`/`not_empty` to `is_none()`/`is_some()`,
62    // and unwrap the inner value before any other assertion runs.
63    if result_is_option && !is_error_context {
64        let assertion_type = assertion.assertion_type.as_str();
65        if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
66            let check = if assertion_type == "is_empty" {
67                "is_none"
68            } else {
69                "is_some"
70            };
71            let _ = writeln!(
72                out,
73                "    assert!({result_var}.{check}(), \"expected Option to be {check}\");"
74            );
75            return;
76        }
77        // For any other assertion shape, unwrap the Option and recurse with a
78        // bare reference variable so the rest of the renderer treats the inner
79        // value as the result.
80        let _ = writeln!(
81            out,
82            "    let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
83        );
84        render_assertion(
85            out,
86            assertion,
87            "r",
88            module,
89            dep_name,
90            is_error_context,
91            unwrapped_fields,
92            field_resolver,
93            result_is_tree,
94            result_is_simple,
95            result_is_vec,
96            false, // already unwrapped
97            returns_result,
98        );
99        return;
100    }
101    let _ = dep_name;
102    // Handle synthetic fields like chunks_have_content (derived assertions).
103    // These are computed expressions, not real struct fields — intercept before
104    // the is_valid_for_result check so they are never treated as field accesses.
105    if let Some(f) = &assertion.field {
106        match f.as_str() {
107            "chunks_have_content" => {
108                render_chunks_have_content(out, result_var, assertion.assertion_type.as_str());
109                return;
110            }
111            "chunks_have_embeddings" => {
112                render_chunks_have_embeddings(out, result_var, assertion.assertion_type.as_str());
113                return;
114            }
115            "embeddings" => {
116                render_embeddings_assertion(out, result_var, assertion);
117                return;
118            }
119            "embedding_dimensions" => {
120                render_embedding_dimensions(out, result_var, assertion);
121                return;
122            }
123            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
124                render_embedding_quality(out, result_var, f, assertion.assertion_type.as_str());
125                return;
126            }
127            "keywords" => {
128                render_keywords_assertion(out, result_var, assertion);
129                return;
130            }
131            "keywords_count" => {
132                render_keywords_count_assertion(out, result_var, assertion);
133                return;
134            }
135            _ => {}
136        }
137    }
138
139    // Skip assertions on fields that don't exist on the result type.
140    if let Some(f) = &assertion.field {
141        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
142            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
143            return;
144        }
145    }
146
147    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
148    let is_unwrapped = assertion
149        .field
150        .as_ref()
151        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
152
153    // When in error context with returns_result=true and accessing a field (not an error check),
154    // we need to unwrap the Result first. The test generator creates a binding like
155    // `let result_ok = result.as_ref().ok();` which we can dereference here.
156    let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
157    let is_field_assertion = !matches!(assertion.assertion_type.as_str(), "error" | "not_error");
158    let effective_result_var = if has_field && is_error_context && returns_result && is_field_assertion {
159        // Dereference the Option<&T> bound as {result_var}_ok
160        format!("{result_var}_ok.as_ref().unwrap()")
161    } else {
162        result_var.to_string()
163    };
164
165    // Determine field access expression:
166    // 1. If the field was unwrapped to a local var, use that local var name.
167    // 2. When result_is_simple, the function returns a plain type (String etc.) — use result_var.
168    // 3. When the field path is exactly the result var name (sentinel: `field: "result"`),
169    //    refer to the result variable directly to avoid emitting `result.result`.
170    // 4. When the result is a Tree, map pseudo-field names to correct Rust expressions.
171    // 5. Otherwise, use the field resolver to generate the accessor.
172    let field_access = match &assertion.field {
173        Some(f) if !f.is_empty() => {
174            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
175                local_var.clone()
176            } else if result_is_simple {
177                // Plain return type (String, Vec<T>, etc.) has no struct fields.
178                // Use the result variable directly so assertions operate on the value itself.
179                effective_result_var.clone()
180            } else if f == result_var {
181                // Sentinel: fixture uses `field: "result"` (or matches the result variable name)
182                // to refer to the whole return value, not a struct field named "result".
183                effective_result_var.clone()
184            } else if result_is_tree {
185                // Tree is an opaque type — its "fields" are accessed via root_node() or
186                // free functions. Map known pseudo-field names to correct Rust expressions.
187                tree_field_access_expr(f, &effective_result_var, module)
188            } else {
189                field_resolver.accessor(f, "rust", &effective_result_var)
190            }
191        }
192        _ => effective_result_var,
193    };
194
195    match assertion.assertion_type.as_str() {
196        "error" => {
197            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
198            if let Some(serde_json::Value::String(msg)) = &assertion.value {
199                let escaped = escape_rust(msg);
200                // Use `.err().unwrap()` instead of `.unwrap_err()` to avoid the
201                // `Ok: Debug` requirement — `BoxStream` (for streaming calls) does
202                // not implement `Debug`, which would cause a compile error.
203                // Use `to_string()` which includes the error prefix (e.g., "unauthorized: ...", "timeout: ...").
204                let _ = writeln!(
205                    out,
206                    "    assert!({result_var}.as_ref().err().unwrap().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
207                );
208            }
209        }
210        "not_error" => {
211            // Handled at call site; nothing extra needed here.
212        }
213        "equals" => {
214            render_equals_assertion(out, assertion, &field_access, is_unwrapped, field_resolver);
215        }
216        "contains" => {
217            if let Some(val) = &assertion.value {
218                let expected = value_to_rust_string(val);
219                let line = format!(
220                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
221                );
222                let _ = writeln!(out, "{line}");
223            }
224        }
225        "contains_all" => {
226            if let Some(values) = &assertion.values {
227                for val in values {
228                    let expected = value_to_rust_string(val);
229                    let line = format!(
230                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
231                    );
232                    let _ = writeln!(out, "{line}");
233                }
234            }
235        }
236        "not_contains" => {
237            if let Some(val) = &assertion.value {
238                let expected = value_to_rust_string(val);
239                let line = format!(
240                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
241                );
242                let _ = writeln!(out, "{line}");
243            }
244        }
245        "not_empty" => {
246            render_not_empty_assertion(
247                out,
248                assertion,
249                &field_access,
250                result_var,
251                result_is_option,
252                is_unwrapped,
253                field_resolver,
254            );
255        }
256        "is_empty" => {
257            render_is_empty_assertion(out, assertion, &field_access, is_unwrapped, field_resolver);
258        }
259        "contains_any" => {
260            if let Some(values) = &assertion.values {
261                let checks: Vec<String> = values
262                    .iter()
263                    .map(|v| {
264                        let expected = value_to_rust_string(v);
265                        format!("{field_access}.contains({expected})")
266                    })
267                    .collect();
268                let joined = checks.join(" || ");
269                let _ = writeln!(
270                    out,
271                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
272                );
273            }
274        }
275        "greater_than" => {
276            if let Some(val) = &assertion.value {
277                // Skip comparisons with negative values against unsigned types (.len() etc.)
278                if val.as_f64().is_some_and(|n| n < 0.0) {
279                    let _ = writeln!(
280                        out,
281                        "    // skipped: greater_than with negative value is always true for unsigned types"
282                    );
283                } else if val.as_u64() == Some(0) {
284                    if field_access.ends_with(".len()") {
285                        // Clippy prefers !is_empty() over len() > 0 for collections.
286                        let base = field_access.strip_suffix(".len()").unwrap();
287                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
288                    } else {
289                        // Scalar types (usize, u64, etc.) — use direct comparison.
290                        let _ = writeln!(out, "    assert!({field_access} > 0, \"expected > 0\");");
291                    }
292                } else {
293                    let lit = numeric_literal(val);
294                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
295                }
296            }
297        }
298        "less_than" => {
299            if let Some(val) = &assertion.value {
300                let lit = numeric_literal(val);
301                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
302            }
303        }
304        "greater_than_or_equal" => {
305            render_gte_assertion(out, assertion, &field_access, is_unwrapped, field_resolver);
306        }
307        "less_than_or_equal" => {
308            if let Some(val) = &assertion.value {
309                let lit = numeric_literal(val);
310                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
311            }
312        }
313        "starts_with" => {
314            if let Some(val) = &assertion.value {
315                let expected = value_to_rust_string(val);
316                let _ = writeln!(
317                    out,
318                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
319                );
320            }
321        }
322        "ends_with" => {
323            if let Some(val) = &assertion.value {
324                let expected = value_to_rust_string(val);
325                let _ = writeln!(
326                    out,
327                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
328                );
329            }
330        }
331        "min_length" => {
332            if let Some(val) = &assertion.value {
333                if let Some(n) = val.as_u64() {
334                    let _ = writeln!(
335                        out,
336                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
337                    );
338                }
339            }
340        }
341        "max_length" => {
342            if let Some(val) = &assertion.value {
343                if let Some(n) = val.as_u64() {
344                    let _ = writeln!(
345                        out,
346                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
347                    );
348                }
349            }
350        }
351        "count_min" => {
352            render_count_min_assertion(out, assertion, &field_access, is_unwrapped, field_resolver);
353        }
354        "count_equals" => {
355            render_count_equals_assertion(out, assertion, &field_access, is_unwrapped, field_resolver);
356        }
357        "is_true" => {
358            let _ = writeln!(out, "    assert!({field_access}, \"expected true\");");
359        }
360        "is_false" => {
361            let _ = writeln!(out, "    assert!(!{field_access}, \"expected false\");");
362        }
363        "method_result" => {
364            render_method_result_assertion(out, assertion, &field_access, result_is_tree, module);
365        }
366        other => {
367            panic!("Rust e2e generator: unsupported assertion type: {other}");
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use std::collections::{HashMap, HashSet};
375
376    use super::*;
377    use crate::field_access::FieldResolver;
378    use crate::fixture::Assertion;
379
380    fn empty_resolver() -> FieldResolver {
381        FieldResolver::new(
382            &HashMap::new(),
383            &HashSet::new(),
384            &HashSet::new(),
385            &HashSet::new(),
386            &HashSet::new(),
387        )
388    }
389
390    fn make_assertion(assertion_type: &str, field: Option<&str>, value: Option<serde_json::Value>) -> Assertion {
391        Assertion {
392            assertion_type: assertion_type.to_string(),
393            field: field.map(|s| s.to_string()),
394            value,
395            ..Default::default()
396        }
397    }
398
399    #[test]
400    fn render_assertion_error_type_emits_is_err_check() {
401        let resolver = empty_resolver();
402        let assertion = make_assertion("error", None, None);
403        let mut out = String::new();
404        render_assertion(
405            &mut out,
406            &assertion,
407            "result",
408            "my_mod",
409            "dep",
410            true,
411            &[],
412            &resolver,
413            false,
414            false,
415            false,
416            false,
417            false,
418        );
419        assert!(out.contains("is_err()"), "got: {out}");
420    }
421
422    #[test]
423    fn render_assertion_vec_result_wraps_in_for_loop() {
424        let resolver = empty_resolver();
425        let assertion = make_assertion("not_empty", Some("content"), None);
426        let mut out = String::new();
427        render_assertion(
428            &mut out,
429            &assertion,
430            "result",
431            "my_mod",
432            "dep",
433            false,
434            &[],
435            &resolver,
436            false,
437            false,
438            true,
439            false,
440            false,
441        );
442        assert!(out.contains("for r in"), "got: {out}");
443    }
444
445    #[test]
446    fn render_assertion_not_empty_bare_result_uses_is_empty() {
447        let resolver = empty_resolver();
448        let assertion = make_assertion("not_empty", None, None);
449        let mut out = String::new();
450        render_assertion(
451            &mut out,
452            &assertion,
453            "result",
454            "my_mod",
455            "dep",
456            false,
457            &[],
458            &resolver,
459            false,
460            false,
461            false,
462            false,
463            false,
464        );
465        assert!(out.contains("is_empty()"), "got: {out}");
466    }
467}