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}