Skip to main content

alef/core/config/e2e/
root.rs

1use super::defaults::*;
2use super::{CallConfig, DependencyMode, HarnessConfig, PackageRef, RegistryConfig, SnippetConfig};
3use crate::core::config::manifest_extras::ManifestExtras;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7
8/// Root e2e configuration from `[e2e]` section of alef.toml.
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10#[serde(deny_unknown_fields)]
11pub struct E2eConfig {
12    /// Directory containing fixture JSON files (default: "fixtures").
13    #[serde(default = "default_fixtures_dir")]
14    pub fixtures: String,
15    /// Output directory for generated e2e test projects (default: "e2e").
16    #[serde(default = "default_output_dir")]
17    pub output: String,
18    /// Repo-root-relative directory holding binary file fixtures referenced by
19    /// `file_path` / `bytes` fixture args (default: "test_documents").
20    ///
21    /// Backends that emit chdir / setup hooks for file-based fixtures resolve
22    /// the relative path from the test-emission directory via
23    /// [`E2eConfig::test_documents_relative_from`]. The default matches the
24    /// sample_core convention; downstream crates whose fixtures don't reference
25    /// files (e.g. sample-llm, which uses pure mock-server fixtures) can leave
26    /// the default in place — backends conditionally emit the setup only when
27    /// fixtures actually need it.
28    #[serde(default = "default_test_documents_dir")]
29    pub test_documents_dir: String,
30    /// Languages to generate e2e tests for. Defaults to top-level `languages` list.
31    #[serde(default)]
32    pub languages: Vec<String>,
33    /// Default function call configuration.
34    pub call: CallConfig,
35    /// Named additional call configurations for multi-function testing.
36    /// Fixtures reference these via the `call` field, e.g. `"call": "embed"`.
37    #[serde(default)]
38    pub calls: HashMap<String, CallConfig>,
39    /// Per-language package reference overrides.
40    #[serde(default)]
41    pub packages: HashMap<String, PackageRef>,
42    /// Per-language extra dependencies to splice into the e2e harness's
43    /// language-native manifest (`e2e/<lang>/package.json` for node/wasm,
44    /// `e2e/python/pyproject.toml` for Python, etc.). Distinct from the
45    /// Rust-binding `extra_dependencies` knob — this one targets the
46    /// host-language test-harness manifest. Keys are canonical language
47    /// names (`node`, `wasm`, `python`, …).
48    #[serde(default)]
49    pub harness_extras: HashMap<String, ManifestExtras>,
50    /// Per-language extra system libraries to link into the generated e2e
51    /// harness's native build alongside the FFI library.
52    ///
53    /// Keyed by canonical language name (`zig`, …); the value is the list of
54    /// bare system-library names (no `lib` prefix, no extension) to link, e.g.
55    /// `["heif"]`. Currently only the Zig e2e generator consumes this: when the
56    /// linked FFI crate is built with feature sets that pull in additional
57    /// native libraries (e.g. libheif via the `all` feature), the strict
58    /// linker on some targets (notably aarch64) cannot resolve those undefined
59    /// symbols unless the e2e build links them explicitly. The libraries must
60    /// already be installed on the build host.
61    ///
62    /// Default is empty, so consumers that do not need extra links are
63    /// unaffected.
64    ///
65    /// Example:
66    /// ```toml
67    /// [e2e.extra_system_libs]
68    /// zig = ["heif"]
69    /// ```
70    #[serde(default)]
71    pub extra_system_libs: HashMap<String, Vec<String>>,
72    /// Per-language formatter commands.
73    #[serde(default)]
74    pub format: HashMap<String, String>,
75    /// Field path aliases: maps fixture field paths to actual API struct paths.
76    /// E.g., "metadata.title" -> "metadata.document.title"
77    /// Supports struct access (`foo.bar`), map access (`foo[key]`), direct fields.
78    #[serde(default)]
79    pub fields: HashMap<String, String>,
80    /// Error field aliases: maps fixture paths after `error.` to fields on the Rust error type.
81    #[serde(default)]
82    pub error_field_aliases: HashMap<String, String>,
83    /// Fields that are Optional/nullable in the return type.
84    /// Rust generators use .as_deref().unwrap_or("") for strings, .is_some() for structs.
85    #[serde(default)]
86    pub fields_optional: HashSet<String>,
87    /// Fields that are arrays/Vecs on the result type.
88    /// When a fixture path like `json_ld.name` traverses an array field, the
89    /// accessor adds `[0]` (or language equivalent) to index into the first element.
90    #[serde(default)]
91    pub fields_array: HashSet<String>,
92    /// Fields where the accessor is a method call (appends `()`) rather than a field access.
93    /// Rust-specific: Java always uses `()`, Python/PHP use field access.
94    /// Listed as the full resolved field path (after alias resolution).
95    /// E.g., `"metadata.format.excel"` means `.excel` should be emitted as `.excel()`.
96    #[serde(default)]
97    pub fields_method_calls: HashSet<String>,
98    /// Known top-level fields on the result type.
99    ///
100    /// When non-empty, assertions whose resolved field path starts with a
101    /// segment that is NOT in this set are emitted as comments (skipped)
102    /// instead of executable assertions.  This prevents broken assertions
103    /// when fixtures reference fields from a different operation (e.g.,
104    /// `batch.completed_count` on a `ScrapeResult`).
105    #[serde(default)]
106    pub result_fields: HashSet<String>,
107    /// Fixture categories excluded from cross-language e2e codegen.
108    ///
109    /// Fixtures whose resolved category matches an entry in this set are
110    /// skipped by every per-language e2e generator — no test is emitted at
111    /// all (no skip directive, no commented-out body). The fixture files stay
112    /// on disk and remain available to Rust integration tests inside the
113    /// consumer crate's own `tests/` directory.
114    ///
115    /// Use this to keep fixtures that exercise internal middleware (cache,
116    /// proxy, budget, hooks, etc.) out of bindings whose public surface does
117    /// not expose those layers.
118    ///
119    /// Example:
120    /// ```toml
121    /// [e2e]
122    /// exclude_categories = ["cache", "proxy", "budget", "hooks"]
123    /// ```
124    #[serde(default)]
125    pub exclude_categories: HashSet<String>,
126    /// C FFI accessor type chain: maps `"{parent_snake_type}.{field}"` to the
127    /// PascalCase return type name (without prefix).
128    ///
129    /// Used by the C e2e generator to emit chained FFI accessor calls for
130    /// nested field paths. The root type is always `conversion_result`.
131    ///
132    /// Example:
133    /// ```toml
134    /// [e2e.fields_c_types]
135    /// "conversion_result.metadata" = "HtmlMetadata"
136    /// "html_metadata.document" = "DocumentMetadata"
137    /// ```
138    #[serde(default)]
139    pub fields_c_types: HashMap<String, String>,
140    /// Fields whose resolved type is an enum in the generated bindings.
141    ///
142    /// When a `contains` / `contains_all` / etc. assertion targets one of these
143    /// fields, language generators that cannot call `.contains()` directly on an
144    /// enum (e.g., Java) will emit a string-conversion call first.  For Java,
145    /// the generated assertion calls `.getValue()` on the enum — the `@JsonValue`
146    /// method that all alef-generated Java enums expose — to obtain the lowercase
147    /// serde string before performing the string comparison.
148    ///
149    /// Both the raw fixture field path (before alias resolution) and the resolved
150    /// path (after alias resolution via `[e2e.fields]`) are accepted, so you can
151    /// use either form:
152    ///
153    /// ```toml
154    /// # Raw fixture field:
155    /// fields_enum = ["links[].link_type", "assets[].category"]
156    /// # …or the resolved (aliased) field name:
157    /// fields_enum = ["links[].link_type", "assets[].asset_category"]
158    /// ```
159    #[serde(default)]
160    pub fields_enum: HashSet<String>,
161    /// Optional fields whose inner type carries a text accessor rather than
162    /// being a plain `String`.
163    ///
164    /// When a `contains` / `equals` assertion targets one of these fields,
165    /// language generators call the language-idiomatic text accessor:
166    ///
167    /// - Go: `field.Text()` instead of `string(*field)`
168    /// - Java: `.map(v -> v.text()).orElse("")` instead of `Objects::toString`
169    /// - C#: `field?.Text()?.Trim()` instead of `field?.ToString()?.Trim()`
170    /// - PHP: `$result->getText()` instead of raw property access
171    ///
172    /// Use this for fields like `content` whose Rust type is `Option<RichTextContent>`
173    /// (a multimodal union) rather than `Option<String>`. The inner type must
174    /// expose a `text()` / `Text()` method that returns the textual representation.
175    ///
176    /// Example:
177    /// ```toml
178    /// [e2e]
179    /// fields_display_as_text = ["content", "choices[0].message.content"]
180    /// ```
181    #[serde(default)]
182    pub fields_display_as_text: HashSet<String>,
183    /// Optional fields whose resolved type is an untyped JSON scalar (Rust
184    /// `Option<serde_json::Value>`, Kotlin `Any?`) rather than `Option<String>`.
185    ///
186    /// The Kotlin generator's string-context expression normally appends
187    /// `.orEmpty()` to nullable fields so `contains`/`equals` assertions don't
188    /// need a safe-call chain — but `.orEmpty()` is a `String?`/`CharSequence?`
189    /// extension and does not resolve on `Any?`, producing
190    /// `Unresolved reference 'orEmpty'` at compile time. Fields listed here
191    /// instead render through a null-safe stringify (`?.toString().orEmpty()`),
192    /// which is also correct — but textually different — for genuine `String?`
193    /// fields, so it is opt-in per field rather than applied universally.
194    ///
195    /// No part of the e2e pipeline carries a field's real Kotlin type through
196    /// to assertion rendering (unlike `fields_enum`, nested paths like
197    /// `action_results[].data` are not auto-detected from the IR), so this
198    /// mirrors `fields_enum`'s manual-declaration convention. Both the raw
199    /// fixture field path and the resolved (aliased) path are accepted.
200    ///
201    /// ```toml
202    /// [e2e]
203    /// fields_json_scalar = ["action_results[].data"]
204    /// ```
205    #[serde(default)]
206    pub fields_json_scalar: HashSet<String>,
207    /// Environment variables every generated e2e suite's setup must set
208    /// before the binding's engine is constructed. Keyed by env-var name;
209    /// values are passed through verbatim.
210    ///
211    /// Each per-language test-harness emitter consumes this map at
212    /// suite-setup time (conftest.py for Python, spec_helper.rb for Ruby,
213    /// TestMain for Go, bootstrap.php for PHP, test_helper.exs for Elixir,
214    /// globalSetup.ts for WASM, assembly fixture for C#, XCTestCase
215    /// classSetUp for Swift, etc.). The injection point sits next to where
216    /// `MOCK_SERVER_URL` is exported so the binding's first call already
217    /// sees the configured environment.
218    ///
219    /// Motivating use case: a binding may require an environment flag to
220    /// allow loopback mock-server calls in e2e tests while keeping production
221    /// URL validation strict by default. The map is intentionally generic so
222    /// consumers can pass any binding-side env-var (feature flags,
223    /// observability toggles, etc.) the same way.
224    ///
225    /// Example:
226    /// ```toml
227    /// [crates.e2e.env]
228    /// ALLOW_PRIVATE_NETWORK = "true"
229    /// ```
230    #[serde(default)]
231    pub env: HashMap<String, String>,
232    /// Server-shaped e2e harness configuration for HTTP fixtures.
233    /// Knobs for code generation that spawn the SUT app and register handlers.
234    #[serde(default)]
235    pub harness: HarnessConfig,
236    /// Dependency mode: `Local` (default) or `Registry`.
237    /// Set at runtime via `--registry` CLI flag; not serialized from TOML.
238    #[serde(skip)]
239    pub dep_mode: DependencyMode,
240    /// Registry-mode configuration from `[e2e.registry]`.
241    #[serde(default)]
242    pub registry: RegistryConfig,
243    /// Optional fixture-driven documentation snippet output.
244    #[serde(default)]
245    pub snippets: Option<SnippetConfig>,
246}
247
248impl E2eConfig {
249    /// Resolve the call config for a fixture. Uses the named call if specified,
250    /// otherwise falls back to the default `[e2e.call]`.
251    pub fn resolve_call(&self, call_name: Option<&str>) -> &CallConfig {
252        match call_name {
253            Some(name) => self.calls.get(name).unwrap_or(&self.call),
254            None => &self.call,
255        }
256    }
257
258    /// Resolve the call config for a fixture, applying `select_when` auto-routing.
259    ///
260    /// When the fixture has an explicit `call` name, that named config is returned
261    /// (same as [`Self::resolve_call`]).  When the fixture has no explicit call, the method
262    /// scans named calls for a [`super::selection::SelectWhen`] condition that matches the fixture's
263    /// shape (id, category, tags, input) and returns the first match.  If no condition
264    /// matches, it falls back to the default `[e2e.call]`.
265    ///
266    /// All non-`None` discriminators on a `SelectWhen` must match (logical AND) for
267    /// the condition to fire. A `SelectWhen` with every field `None` never matches —
268    /// at least one discriminator must be set.
269    pub fn resolve_call_for_fixture(
270        &self,
271        call_name: Option<&str>,
272        fixture_id: &str,
273        fixture_category: &str,
274        fixture_tags: &[String],
275        fixture_input: &serde_json::Value,
276    ) -> &CallConfig {
277        if let Some(name) = call_name {
278            return self.calls.get(name).unwrap_or(&self.call);
279        }
280        // Auto-route by select_when condition. Deterministic order: sort by call name.
281        let mut names: Vec<&String> = self.calls.keys().collect();
282        names.sort();
283        for name in names {
284            let call_config = &self.calls[name];
285            if let Some(sel) = &call_config.select_when
286                && sel.matches(fixture_id, fixture_category, fixture_tags, fixture_input)
287            {
288                return call_config;
289            }
290        }
291        &self.call
292    }
293
294    /// Resolve the effective package reference for a language.
295    ///
296    /// In registry mode, entries from `[e2e.registry.packages]` are merged on
297    /// top of the base `[e2e.packages]` — registry overrides win for any field
298    /// that is `Some`.
299    pub fn resolve_package(&self, lang: &str) -> Option<PackageRef> {
300        let base = self.packages.get(lang);
301        if self.dep_mode == DependencyMode::Registry {
302            let reg = self.registry.packages.get(lang);
303            match (base, reg) {
304                (Some(b), Some(r)) => Some(PackageRef {
305                    name: r.name.clone().or_else(|| b.name.clone()),
306                    path: r.path.clone().or_else(|| b.path.clone()),
307                    module: r.module.clone().or_else(|| b.module.clone()),
308                    version: r.version.clone().or_else(|| b.version.clone()),
309                    hash: r.hash.clone().or_else(|| b.hash.clone()),
310                    platform_hashes: if r.platform_hashes.is_empty() {
311                        b.platform_hashes.clone()
312                    } else {
313                        r.platform_hashes.clone()
314                    },
315                    tap: r.tap.clone().or_else(|| b.tap.clone()),
316                    cli_formula: r.cli_formula.clone().or_else(|| b.cli_formula.clone()),
317                    ffi_formula: r.ffi_formula.clone().or_else(|| b.ffi_formula.clone()),
318                    // Registry cli_tests win; fall back to base when registry has none.
319                    cli_tests: if r.cli_tests.is_empty() {
320                        b.cli_tests.clone()
321                    } else {
322                        r.cli_tests.clone()
323                    },
324                }),
325                (None, Some(r)) => Some(r.clone()),
326                (Some(b), None) => Some(b.clone()),
327                (None, None) => None,
328            }
329        } else {
330            base.cloned()
331        }
332    }
333
334    /// Return the effective `result_fields` for `call`.
335    ///
336    /// Returns `call.result_fields` when non-empty, otherwise the global
337    /// `self.result_fields`.
338    pub fn effective_result_fields<'a>(&'a self, call: &'a CallConfig) -> &'a HashSet<String> {
339        if !call.result_fields.is_empty() {
340            &call.result_fields
341        } else {
342            &self.result_fields
343        }
344    }
345
346    /// Return the effective `fields` alias map for `call`.
347    pub fn effective_fields<'a>(&'a self, call: &'a CallConfig) -> &'a HashMap<String, String> {
348        if !call.fields.is_empty() {
349            &call.fields
350        } else {
351            &self.fields
352        }
353    }
354
355    /// Return the effective `fields_optional` for `call`.
356    pub fn effective_fields_optional<'a>(&'a self, call: &'a CallConfig) -> &'a HashSet<String> {
357        if !call.fields_optional.is_empty() {
358            &call.fields_optional
359        } else {
360            &self.fields_optional
361        }
362    }
363
364    /// Return the effective `fields_array` for `call`.
365    pub fn effective_fields_array<'a>(&'a self, call: &'a CallConfig) -> &'a HashSet<String> {
366        if !call.fields_array.is_empty() {
367            &call.fields_array
368        } else {
369            &self.fields_array
370        }
371    }
372
373    /// Return the effective `fields_method_calls` for `call`.
374    pub fn effective_fields_method_calls<'a>(&'a self, call: &'a CallConfig) -> &'a HashSet<String> {
375        if !call.fields_method_calls.is_empty() {
376            &call.fields_method_calls
377        } else {
378            &self.fields_method_calls
379        }
380    }
381
382    /// Return the effective `fields_enum` for `call`.
383    pub fn effective_fields_enum<'a>(&'a self, call: &'a CallConfig) -> &'a HashSet<String> {
384        if !call.fields_enum.is_empty() {
385            &call.fields_enum
386        } else {
387            &self.fields_enum
388        }
389    }
390
391    /// Return the effective `fields_display_as_text` for `call`.
392    pub fn effective_fields_display_as_text<'a>(&'a self, call: &'a CallConfig) -> &'a HashSet<String> {
393        if !call.fields_display_as_text.is_empty() {
394            &call.fields_display_as_text
395        } else {
396            &self.fields_display_as_text
397        }
398    }
399
400    /// Return the effective `fields_json_scalar` for `call`.
401    pub fn effective_fields_json_scalar<'a>(&'a self, call: &'a CallConfig) -> &'a HashSet<String> {
402        if !call.fields_json_scalar.is_empty() {
403            &call.fields_json_scalar
404        } else {
405            &self.fields_json_scalar
406        }
407    }
408
409    /// Return the effective `fields_c_types` for `call`.
410    pub fn effective_fields_c_types<'a>(&'a self, call: &'a CallConfig) -> &'a HashMap<String, String> {
411        if !call.fields_c_types.is_empty() {
412            &call.fields_c_types
413        } else {
414            &self.fields_c_types
415        }
416    }
417
418    /// Return the effective output directory: `registry.output` in registry
419    /// mode, `output` otherwise.
420    pub fn effective_output(&self) -> &str {
421        if self.dep_mode == DependencyMode::Registry {
422            &self.registry.output
423        } else {
424            &self.output
425        }
426    }
427
428    /// Extra system libraries to link for a given language's e2e harness build.
429    ///
430    /// Returns an empty slice when none are configured for `lang`.
431    pub fn extra_system_libs_for(&self, lang: &str) -> &[String] {
432        self.extra_system_libs.get(lang).map_or(&[], Vec::as_slice)
433    }
434
435    /// Relative path from a backend's emission directory to the
436    /// `test_documents_dir` at the repo root.
437    ///
438    /// `emission_depth` counts the number of additional `../` segments needed
439    /// to reach `<output>/<lang>/` from where the file is being emitted:
440    ///
441    /// * `0` — emitted directly at `e2e/<lang>/` (e.g. dart, zig `build.zig`)
442    /// * `1` — emitted at `e2e/<lang>/<sub>/` (e.g. ruby `spec/`, R `tests/`)
443    /// * `2` — emitted at `e2e/<lang>/<sub1>/<sub2>/`
444    ///
445    /// The base prefix is two segments above `<output>/<lang>/` (i.e.
446    /// `../../`), matching the canonical layout where `<output>` (default
447    /// `"e2e"`) sits at the repo root next to the configured
448    /// `test_documents_dir`.
449    pub fn test_documents_relative_from(&self, emission_depth: usize) -> String {
450        let mut up = String::from("../../");
451        for _ in 0..emission_depth {
452            up.push_str("../");
453        }
454        format!("{up}{}", self.test_documents_dir)
455    }
456}
457
458impl Default for E2eConfig {
459    fn default() -> Self {
460        Self {
461            fixtures: default_fixtures_dir(),
462            output: default_output_dir(),
463            test_documents_dir: default_test_documents_dir(),
464            languages: Vec::new(),
465            call: CallConfig::default(),
466            calls: HashMap::new(),
467            packages: HashMap::new(),
468            harness_extras: HashMap::new(),
469            extra_system_libs: HashMap::new(),
470            format: HashMap::new(),
471            fields: HashMap::new(),
472            error_field_aliases: HashMap::new(),
473            fields_optional: HashSet::new(),
474            fields_array: HashSet::new(),
475            fields_method_calls: HashSet::new(),
476            result_fields: HashSet::new(),
477            exclude_categories: HashSet::new(),
478            fields_c_types: HashMap::new(),
479            fields_enum: HashSet::new(),
480            fields_display_as_text: HashSet::new(),
481            fields_json_scalar: HashSet::new(),
482            env: HashMap::new(),
483            harness: HarnessConfig::default(),
484            dep_mode: DependencyMode::default(),
485            registry: RegistryConfig::default(),
486            snippets: None,
487        }
488    }
489}