Skip to main content

alef_e2e/codegen/
streaming_assertions.rs

1//! Shared streaming-virtual-fields module for e2e test codegen.
2//!
3//! Chat-stream fixtures assert on "virtual" fields that don't exist on the
4//! stream result type itself — `chunks`, `chunks.length`, `stream_content`,
5//! `stream_complete`, `no_chunks_after_done`, `tool_calls`, `finish_reason`.
6//! These fields resolve against the *collected* list of chunks produced by
7//! draining the stream.
8//!
9//! [`StreamingFieldResolver`] provides two entry points:
10//! - [`StreamingFieldResolver::accessor`] — the language-specific expression
11//!   for a virtual field given a local variable that holds the collected list.
12//! - [`StreamingFieldResolver::collect_snippet`] — the language-specific
13//!   code snippet that drains a stream variable into the collected list.
14//!
15//! ## Convention
16//!
17//! The `chunks_var` parameter is the local variable name that holds the
18//! collected list (default: `"chunks"`).  The `stream_var` parameter is the
19//! result variable produced by the stream call (default: `"result"`).
20//!
21//! The set of streaming-virtual field names handled by this module:
22//! - `chunks`              → the collected list itself
23//! - `chunks.length`       → length/count of the collected list
24//! - `stream_content`      → concatenation of all delta content strings
25//! - `stream_complete`     → boolean — last chunk has a non-null finish_reason
26//! - `no_chunks_after_done` → structural invariant (true by construction for
27//!   channel/iterator-based APIs once the channel is closed; emitted as
28//!   `assert!(true)` / `assertTrue` for languages without post-DONE chunk plumbing)
29//! - `tool_calls`          → flat list of tool_calls from all chunk deltas
30//! - `finish_reason`       → finish_reason string from the last chunk
31
32/// The set of field names treated as streaming-virtual fields.
33pub const STREAMING_VIRTUAL_FIELDS: &[&str] = &[
34    "chunks",
35    "chunks.length",
36    "stream_content",
37    "stream_complete",
38    "no_chunks_after_done",
39    "tool_calls",
40    "finish_reason",
41    // Crawl-stream event-variant predicates: resolve against the collected
42    // `chunks` list where each item is a tagged-union CrawlEvent (`page` /
43    // `error` / `complete`). `event_count_min` is a synonym for the chunk
44    // count, used with `greater_than_or_equal` to assert "at least N events".
45    "stream.has_page_event",
46    "stream.has_error_event",
47    "stream.has_complete_event",
48    "stream.event_count_min",
49];
50
51/// The set of streaming-virtual root names that may have deep-path continuations.
52///
53/// A field like `tool_calls[0].function.name` starts with `tool_calls` and has
54/// a continuation `[0].function.name`. These are handled by
55/// [`StreamingFieldResolver::accessor`] via the deep-path logic.
56///
57/// `usage` is a stream-level root: `usage.total_tokens` resolves against the
58/// last chunk that carried a usage payload (accessed via the collected chunks
59/// list). Python accessor: `(chunks[-1].usage if chunks else None)`.
60const STREAMING_VIRTUAL_ROOTS: &[&str] = &["tool_calls", "finish_reason"];
61
62/// Returns `true` when `field` is a streaming-virtual field name, including
63/// deep-nested paths that start with a known streaming-virtual root.
64///
65/// Examples that return `true`:
66/// - `"tool_calls"` (exact root)
67/// - `"tool_calls[0].function.name"` (deep path)
68/// - `"tool_calls[0].id"` (deep path)
69pub fn is_streaming_virtual_field(field: &str) -> bool {
70    if STREAMING_VIRTUAL_FIELDS.contains(&field) {
71        return true;
72    }
73    // Check deep-path prefixes: `tool_calls[…` or `tool_calls.`
74    for root in STREAMING_VIRTUAL_ROOTS {
75        if field.len() > root.len() && field.starts_with(root) {
76            let rest = &field[root.len()..];
77            if rest.starts_with('[') || rest.starts_with('.') {
78                return true;
79            }
80        }
81    }
82    false
83}
84
85/// Split a field path into `(root, tail)` when it starts with a streaming-virtual
86/// root and has a continuation.
87///
88/// Returns `None` when the field is an exact root match (no tail) or is not a
89/// streaming-virtual root at all.
90fn split_streaming_deep_path(field: &str) -> Option<(&str, &str)> {
91    for root in STREAMING_VIRTUAL_ROOTS {
92        if field.len() > root.len() && field.starts_with(root) {
93            let rest = &field[root.len()..];
94            if rest.starts_with('[') || rest.starts_with('.') {
95                return Some((root, rest));
96            }
97        }
98    }
99    None
100}
101
102/// Field names that unambiguously imply a streaming test (no overlap with
103/// non-streaming response shapes). `usage`, `tool_calls`, and `finish_reason`
104/// are intentionally excluded — they exist on non-streaming responses too
105/// (`usage.total_tokens` on ChatCompletionResponse, `choices[0].finish_reason`,
106/// etc.) and would otherwise drag non-streaming fixtures into streaming
107/// codegen.
108const STREAMING_ONLY_AUTO_DETECT_FIELDS: &[&str] = &[
109    "chunks",
110    "chunks.length",
111    "stream_content",
112    "stream_complete",
113    "no_chunks_after_done",
114    "stream.has_page_event",
115    "stream.has_error_event",
116    "stream.has_complete_event",
117    "stream.event_count_min",
118];
119
120/// Resolve whether a fixture should be treated as streaming, honoring the
121/// call-level three-valued opt-in/out (`CallConfig::streaming`):
122///
123/// - `Some(true)` → forced streaming.
124/// - `Some(false)` → forced non-streaming (skip the auto-detect even when an
125///   assertion references a streaming-virtual-field name like `chunks`).
126/// - `None` → auto-detect: streaming iff the fixture has a streaming mock
127///   (`mock_response.stream_chunks`) or any assertion references one of the
128///   unambiguous streaming-only field names.
129///
130/// All backends should use this helper so the opt-out is respected uniformly.
131pub fn resolve_is_streaming(fixture: &crate::fixture::Fixture, call_streaming: Option<bool>) -> bool {
132    if let Some(forced) = call_streaming {
133        return forced;
134    }
135    fixture.is_streaming_mock()
136        || fixture.assertions.iter().any(|a| {
137            a.field
138                .as_deref()
139                .is_some_and(|f| !f.is_empty() && STREAMING_ONLY_AUTO_DETECT_FIELDS.contains(&f))
140        })
141}
142
143/// Shared streaming-virtual-fields resolver for e2e test codegen.
144pub struct StreamingFieldResolver;
145
146impl StreamingFieldResolver {
147    /// Returns the language-specific expression for a streaming-virtual field,
148    /// given `chunks_var` (the collected-list local name) and `lang`.
149    ///
150    /// Returns `None` when the field name is not a known streaming-virtual
151    /// field or the language has no streaming support.
152    pub fn accessor(field: &str, lang: &str, chunks_var: &str) -> Option<String> {
153        match field {
154            "chunks" => Some(match lang {
155                // Zig ArrayList does not expose .len directly; must use .items
156                "zig" => format!("{chunks_var}.items"),
157                // PHP variables require `$` sigil — bareword `chunks` is parsed as a
158                // constant reference and triggers "Undefined constant" errors.
159                "php" => format!("${chunks_var}"),
160                _ => chunks_var.to_string(),
161            }),
162
163            "chunks.length" => Some(match lang {
164                "rust" => format!("{chunks_var}.len()"),
165                "go" => format!("len({chunks_var})"),
166                "python" => format!("len({chunks_var})"),
167                "php" => format!("count(${chunks_var})"),
168                "elixir" => format!("length({chunks_var})"),
169                // kotlin List.size is a property (not .length)
170                "kotlin" => format!("{chunks_var}.size"),
171                // zig: chunks_var is ArrayList([]u8); use .items.len
172                "zig" => format!("{chunks_var}.items.len"),
173                // Swift Array uses .count (Collection protocol)
174                "swift" => format!("{chunks_var}.count"),
175                // node/wasm/typescript use .length
176                _ => format!("{chunks_var}.length"),
177            }),
178
179            "stream_content" => Some(match lang {
180                "rust" => {
181                    format!(
182                        "{chunks_var}.iter().map(|c| c.choices.first().and_then(|ch| ch.delta.content.as_deref()).unwrap_or(\"\")).collect::<String>()"
183                    )
184                }
185                "go" => {
186                    // Go: chunks is []pkg.ChatCompletionChunk
187                    format!(
188                        "func() string {{ var s string; for _, c := range {chunks_var} {{ if len(c.Choices) > 0 && c.Choices[0].Delta.Content != nil {{ s += *c.Choices[0].Delta.Content }} }}; return s }}()"
189                    )
190                }
191                "java" => {
192                    format!(
193                        "{chunks_var}.stream().map(c -> c.choices().stream().findFirst().map(ch -> ch.delta().content() != null ? ch.delta().content() : \"\").orElse(\"\")).collect(java.util.stream.Collectors.joining())"
194                    )
195                }
196                "php" => {
197                    format!("implode('', array_map(fn($c) => $c->choices[0]->delta->content ?? '', ${chunks_var}))")
198                }
199                "kotlin" => {
200                    // Kotlin: chunks is List<ChatCompletionChunk> (Java records via typealias).
201                    // choices() / delta() / content() are Java record accessor methods.
202                    format!(
203                        "{chunks_var}.joinToString(\"\") {{ it.choices()?.firstOrNull()?.delta()?.content() ?: \"\" }}"
204                    )
205                }
206                "kotlin_android" => {
207                    // kotlin-android: data classes use Kotlin property access (no parens).
208                    format!("{chunks_var}.joinToString(\"\") {{ it.choices?.firstOrNull()?.delta?.content ?: \"\" }}")
209                }
210                "elixir" => {
211                    // StreamDelta has all fields optional with skip_serializing_if, so
212                    // absent fields are not present as keys in the Jason-decoded map.
213                    // Use Map.get with defaults to avoid KeyError on absent :content.
214                    format!(
215                        "{chunks_var} |> Enum.map(fn c -> (Enum.at(c.choices, 0) || %{{}}) |> Map.get(:delta, %{{}}) |> Map.get(:content, \"\") end) |> Enum.join(\"\")"
216                    )
217                }
218                "python" => {
219                    format!("\"\".join(c.choices[0].delta.content or \"\" for c in {chunks_var} if c.choices)")
220                }
221                "zig" => {
222                    // Zig: `{chunks_var}_content` is a `std.ArrayList(u8)` populated by
223                    // the collect snippet. `.items` gives a `[]u8` slice of the content.
224                    format!("{chunks_var}_content.items")
225                }
226                // Swift: chunks is [ChatCompletionChunk] (swift-bridge class objects).
227                // choices() → RustVec<StreamChoice> (Collection, so .first is available).
228                // delta() → StreamDelta (non-optional). content() → RustString? → .toString().
229                "swift" => {
230                    format!(
231                        "{chunks_var}.map {{ c in c.choices().first.flatMap {{ ch in ch.delta().content()?.toString() }} ?? \"\" }}.joined()"
232                    )
233                }
234                // node/wasm/typescript
235                _ => {
236                    format!("{chunks_var}.map((c: any) => c.choices?.[0]?.delta?.content ?? '').join('')")
237                }
238            }),
239
240            "stream_complete" => Some(match lang {
241                "rust" => {
242                    format!(
243                        "{chunks_var}.last().and_then(|c| c.choices.first()).and_then(|ch| ch.finish_reason.as_ref()).is_some()"
244                    )
245                }
246                "go" => {
247                    format!(
248                        "func() bool {{ if len({chunks_var}) == 0 {{ return false }}; last := {chunks_var}[len({chunks_var})-1]; return len(last.Choices) > 0 && last.Choices[0].FinishReason != nil }}()"
249                    )
250                }
251                "java" => {
252                    format!(
253                        "!{chunks_var}.isEmpty() && {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().flatMap(ch -> java.util.Optional.ofNullable(ch.finishReason())).isPresent()"
254                    )
255                }
256                "php" => {
257                    // PHP streaming chunks come from `json_decode` of the binding's JSON
258                    // string return. The PHP binding serializes with rename_all = "camelCase",
259                    // so use `finishReason` (camelCase) to match the emitted JSON keys.
260                    format!("!empty(${chunks_var}) && isset(end(${chunks_var})->choices[0]->finishReason)")
261                }
262                "kotlin" => {
263                    // Kotlin: use isNotEmpty() + last() + safe-call chain
264                    format!(
265                        "{chunks_var}.isNotEmpty() && {chunks_var}.last().choices()?.firstOrNull()?.finishReason() != null"
266                    )
267                }
268                "kotlin_android" => {
269                    // kotlin-android: data classes use Kotlin property access (no parens).
270                    format!(
271                        "{chunks_var}.isNotEmpty() && {chunks_var}.last().choices?.firstOrNull()?.finishReason != null"
272                    )
273                }
274                "python" => {
275                    format!("bool({chunks_var}) and {chunks_var}[-1].choices[0].finish_reason is not None")
276                }
277                "elixir" => {
278                    format!("Enum.at(List.last({chunks_var}).choices, 0).finish_reason != nil")
279                }
280                // zig: the collect snippet exhausts the stream; check last chunk JSON
281                // was collected (chunks.items is non-empty) as a proxy for completion.
282                "zig" => {
283                    format!("{chunks_var}.items.len > 0")
284                }
285                // Swift: chunks is [ChatCompletionChunk] (swift-bridge class objects).
286                // choices() → RustVec<StreamChoice> (Collection), .first is Optional.
287                // finish_reason() → RustString? (non-nil when the stream completed).
288                "swift" => {
289                    format!("!{chunks_var}.isEmpty && {chunks_var}.last!.choices().first?.finish_reason() != nil")
290                }
291                // node/wasm/typescript
292                _ => {
293                    format!(
294                        "{chunks_var}.length > 0 && {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason != null"
295                    )
296                }
297            }),
298
299            // no_chunks_after_done is a structural invariant: once the stream
300            // closes (channel drained / iterator exhausted), no further chunks
301            // can arrive.  We assert `true` as a compile-time proof of intent.
302            "no_chunks_after_done" => Some(match lang {
303                "rust" => "true".to_string(),
304                "go" => "true".to_string(),
305                "java" => "true".to_string(),
306                "php" => "true".to_string(),
307                _ => "true".to_string(),
308            }),
309
310            // Crawl-stream event-variant predicates.
311            //
312            // Each chunk is a tagged union (CrawlEvent::Page / Error / Complete).
313            // The accessor returns a language-native boolean expression that is
314            // `true` iff any chunk in the collected list matches the variant.
315            //
316            // PHP and WASM intentionally return `None`: PHP's crawl-stream is
317            // exposed as eager JSON (see `chunks_var` collect_snippet) and WASM
318            // does not support streaming on `wasm32` targets.
319            "stream.has_page_event" => has_event_variant_accessor(lang, chunks_var, EventVariant::Page),
320            "stream.has_error_event" => has_event_variant_accessor(lang, chunks_var, EventVariant::Error),
321            "stream.has_complete_event" => has_event_variant_accessor(lang, chunks_var, EventVariant::Complete),
322
323            // event_count_min is the collected chunks count — used with
324            // `greater_than_or_equal` assertions on the chunk count.  Mirrors
325            // `chunks.length` per-language.
326            "stream.event_count_min" => Self::accessor("chunks.length", lang, chunks_var),
327
328            "tool_calls" => Some(match lang {
329                "rust" => {
330                    format!(
331                        "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten())).collect::<Vec<_>>()"
332                    )
333                }
334                "go" => {
335                    // StreamDelta.ToolCalls is `[]StreamToolCall` (slice, not pointer).
336                    // Return the typed slice so deep-path accessors like `tool_calls[0].function.name`
337                    // can index and access typed fields.
338                    format!(
339                        "func() []pkg.StreamToolCall {{ var tc []pkg.StreamToolCall; for _, c := range {chunks_var} {{ for _, ch := range c.Choices {{ tc = append(tc, ch.Delta.ToolCalls...) }} }}; return tc }}()"
340                    )
341                }
342                "java" => {
343                    format!(
344                        "{chunks_var}.stream().flatMap(c -> c.choices().stream()).flatMap(ch -> ch.delta().toolCalls() != null ? ch.delta().toolCalls().stream() : java.util.stream.Stream.empty()).toList()"
345                    )
346                }
347                "php" => {
348                    // PHP streaming chunks are json_decoded stdClass. The PHP binding
349                    // serializes with rename_all = "camelCase", so use `toolCalls`.
350                    format!(
351                        "array_merge(...array_map(fn($c) => $c->choices[0]->delta->toolCalls ?? [], ${chunks_var}))"
352                    )
353                }
354                "kotlin" => {
355                    // Kotlin: flatten tool_calls from all chunk deltas into one list
356                    format!(
357                        "{chunks_var}.flatMap {{ c -> c.choices()?.flatMap {{ ch -> ch.delta()?.toolCalls() ?: emptyList() }} ?: emptyList() }}"
358                    )
359                }
360                "kotlin_android" => {
361                    // kotlin-android: data classes use Kotlin property access (no parens).
362                    format!(
363                        "{chunks_var}.flatMap {{ c -> c.choices?.flatMap {{ ch -> ch.delta?.toolCalls ?: emptyList() }} ?: emptyList() }}"
364                    )
365                }
366                "python" => {
367                    format!(
368                        "[t for c in {chunks_var} for ch in (c.choices or []) for t in (ch.delta.tool_calls or [])]"
369                    )
370                }
371                "elixir" => {
372                    format!(
373                        "{chunks_var} |> Enum.flat_map(fn c -> (List.first(c.choices) || %{{}}).delta |> Map.get(:tool_calls, []) end)"
374                    )
375                }
376                // Zig: tool_calls count from all chunk deltas
377                "zig" => {
378                    format!("{chunks_var}.items")
379                }
380                // Swift: chunks is [ChatCompletionChunk] (swift-bridge class objects).
381                // choices() → RustVec<StreamChoice> (Collection). delta() → StreamDelta.
382                // tool_calls() → RustVec<StreamToolCall>?
383                // Use an explicit return type annotation on the outer closure so Swift's
384                // type checker doesn't pick the Optional overload of flatMap.  Guard-let
385                // unwrapping avoids ambiguous ?? operator precedence.
386                "swift" => {
387                    format!(
388                        "{chunks_var}.flatMap {{ c -> [StreamToolCallRef] in guard let ch = c.choices().first, let tcs = ch.delta().tool_calls() else {{ return [] }}; return Array(tcs) }}"
389                    )
390                }
391                _ => {
392                    format!("{chunks_var}.flatMap((c: any) => c.choices?.[0]?.delta?.toolCalls ?? [])")
393                }
394            }),
395
396            "finish_reason" => Some(match lang {
397                "rust" => {
398                    // ChatCompletionChunk's finish_reason is Option<FinishReason> (enum, not
399                    // String). Display impl writes the JSON wire form (e.g. "tool_calls").
400                    format!(
401                        "{chunks_var}.last().and_then(|c| c.choices.first()).and_then(|ch| ch.finish_reason.as_ref()).map(|v| v.to_string()).unwrap_or_default()"
402                    )
403                }
404                "go" => {
405                    // FinishReason is a typed alias (`type FinishReason string`) in bindings,
406                    // so cast to string explicitly to match the assertion target type.
407                    format!(
408                        "func() string {{ if len({chunks_var}) == 0 {{ return \"\" }}; last := {chunks_var}[len({chunks_var})-1]; if len(last.Choices) > 0 && last.Choices[0].FinishReason != nil {{ return string(*last.Choices[0].FinishReason) }}; return \"\" }}()"
409                    )
410                }
411                "java" => {
412                    // FinishReason.getValue() returns the JSON wire string (e.g. "tool_calls").
413                    // Without it, assertEquals(String, FinishReason) fails because Object.equals
414                    // doesn't cross types even when toString() matches.
415                    format!(
416                        "({chunks_var}.isEmpty() ? null : {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().map(ch -> ch.finishReason() == null ? null : ch.finishReason().getValue()).orElse(null))"
417                    )
418                }
419                "php" => {
420                    // PHP streaming chunks are json_decoded stdClass. The PHP binding
421                    // serializes with rename_all = "camelCase", so use `finishReason`.
422                    format!("(!empty(${chunks_var}) ? (end(${chunks_var})->choices[0]->finishReason ?? null) : null)")
423                }
424                "kotlin" => {
425                    // Returns the string value of the finish_reason enum from the last chunk.
426                    // FinishReason.getValue() returns the JSON wire string (e.g. "tool_calls").
427                    format!(
428                        "(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().choices()?.firstOrNull()?.finishReason()?.getValue())"
429                    )
430                }
431                "kotlin_android" => {
432                    // kotlin-android: plain Kotlin enum class uses .name.lowercase() for wire string.
433                    format!(
434                        "(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().choices?.firstOrNull()?.finishReason?.name?.lowercase())"
435                    )
436                }
437                "python" => {
438                    // FinishReason is a PyO3 enum object, not a plain string.
439                    // Wrap in str() so callers can do `.strip()` / string comparisons
440                    // without `AttributeError: 'FinishReason' has no attribute 'strip'`.
441                    format!(
442                        "(str({chunks_var}[-1].choices[0].finish_reason) if {chunks_var} and {chunks_var}[-1].choices else None)"
443                    )
444                }
445                "elixir" => {
446                    format!("Enum.at(List.last({chunks_var}).choices, 0).finish_reason")
447                }
448                // Zig: finish_reason from the last chunk's JSON via an inline labeled block.
449                // Returns `[]const u8` (unwrapped with orelse "" for expectEqualStrings).
450                "zig" => {
451                    format!(
452                        "(blk: {{ if ({chunks_var}.items.len == 0) break :blk \"\"; var _lcp = std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {chunks_var}.items[{chunks_var}.items.len - 1], .{{}}) catch break :blk \"\"; defer _lcp.deinit(); if (_lcp.value.object.get(\"choices\")) |_lchs| if (_lchs.array.items.len > 0) if (_lchs.array.items[0].object.get(\"finish_reason\")) |_fr| if (_fr == .string) break :blk _fr.string; break :blk \"\"; }})"
453                    )
454                }
455                // Swift: FinishReason is a swift-bridge opaque class with .to_string() → RustString.
456                // finish_reason() on StreamChoiceRef returns RustString? (the raw wire string).
457                // Return nil when chunks is empty or the last choice has no finish_reason.
458                "swift" => {
459                    format!(
460                        "({chunks_var}.isEmpty ? nil : {chunks_var}.last!.choices().first?.finish_reason()?.toString())"
461                    )
462                }
463                _ => {
464                    format!(
465                        "{chunks_var}.length > 0 ? {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason : undefined"
466                    )
467                }
468            }),
469
470            // `usage` is a stream-level virtual root: resolves against the last
471            // chunk that carried a usage payload.  Deep paths like `usage.total_tokens`
472            // are handled by the deep-path logic in the `_` arm below (root=`usage`,
473            // tail=`.total_tokens`), which calls this base accessor and appends the tail.
474            "usage" => Some(match lang {
475                "python" => {
476                    // Access the last chunk's usage object (may be None).
477                    // Deep paths like usage.total_tokens are rendered as:
478                    //   (chunks[-1].usage if chunks else None).total_tokens
479                    format!("({chunks_var}[-1].usage if {chunks_var} else None)")
480                }
481                "rust" => {
482                    format!("{chunks_var}.last().and_then(|c| c.usage.as_ref())")
483                }
484                "go" => {
485                    format!(
486                        "func() interface{{}} {{ if len({chunks_var}) == 0 {{ return nil }}; return {chunks_var}[len({chunks_var})-1].Usage }}()"
487                    )
488                }
489                "java" => {
490                    format!("({chunks_var}.isEmpty() ? null : {chunks_var}.get({chunks_var}.size()-1).usage())")
491                }
492                "kotlin" => {
493                    format!("(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().usage())")
494                }
495                "kotlin_android" => {
496                    // kotlin-android: data classes use Kotlin property access (no parens).
497                    format!("(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().usage)")
498                }
499                "php" => {
500                    format!("(!empty(${chunks_var}) ? end(${chunks_var})->usage ?? null : null)")
501                }
502                "elixir" => {
503                    format!("(if length({chunks_var}) > 0, do: List.last({chunks_var}).usage, else: nil)")
504                }
505                // Swift: usage() on ChatCompletionChunkRef returns Usage? (swift-bridge class).
506                "swift" => {
507                    format!("({chunks_var}.isEmpty ? nil : {chunks_var}.last!.usage())")
508                }
509                _ => {
510                    format!("({chunks_var}.length > 0 ? {chunks_var}[{chunks_var}.length - 1].usage : undefined)")
511                }
512            }),
513
514            _ => {
515                // Deep-path: e.g. `tool_calls[0].function.name`
516                // Split into root + tail, get the root's inline expression, then
517                // render the tail (index + fields) in a per-language style on top.
518                if let Some((root, tail)) = split_streaming_deep_path(field) {
519                    // Rust needs Option-aware chaining for the StreamToolCall fields
520                    // (function/id are Option<...>). The generic tail renderer can't
521                    // infer Option-wrapping, so we emit rust-specific idiom here.
522                    if lang == "rust" && root == "tool_calls" {
523                        return Some(render_rust_tool_calls_deep(chunks_var, tail));
524                    }
525                    // Swift: StreamToolCallRef fields are swift-bridge methods returning
526                    // Optional.  The generic render_deep_tail doesn't know to add `()`
527                    // or optional-chain with `?.`, so use a dedicated renderer.
528                    if lang == "swift" && root == "tool_calls" {
529                        let root_expr = Self::accessor(root, lang, chunks_var)?;
530                        return Some(render_swift_tool_calls_deep(&root_expr, tail));
531                    }
532                    // Zig stores stream chunks as JSON strings (`[]const u8`) in
533                    // `chunks: ArrayList([]u8)`, not typed `ChatCompletionChunk`
534                    // records. A deep `tool_calls[N].function.name` access would
535                    // require parsing each chunk's JSON inline — rather than
536                    // emit code that won't compile, signal "unsupported" so the
537                    // assertion is skipped at the call site.
538                    if lang == "zig" && root == "tool_calls" {
539                        return None;
540                    }
541                    let root_expr = Self::accessor(root, lang, chunks_var)?;
542                    Some(render_deep_tail(&root_expr, tail, lang))
543                } else {
544                    None
545                }
546            }
547        }
548    }
549
550    /// Returns the language-specific stream-collect-into-list snippet that
551    /// produces `chunks_var` from `stream_var`.
552    ///
553    /// Returns `None` when the language has no streaming collect support or
554    /// when the collect snippet cannot be expressed generically.
555    pub fn collect_snippet(lang: &str, stream_var: &str, chunks_var: &str) -> Option<String> {
556        match lang {
557            "rust" => Some(format!(
558                "let {chunks_var}: Vec<_> = tokio_stream::StreamExt::collect::<Vec<_>>({stream_var}).await\n        .into_iter()\n        .map(|r| r.expect(\"stream item failed\"))\n        .collect();"
559            )),
560            "go" => Some(format!(
561                "var {chunks_var} []pkg.ChatCompletionChunk\n\tfor chunk := range {stream_var} {{\n\t\t{chunks_var} = append({chunks_var}, chunk)\n\t}}"
562            )),
563            "java" => Some(format!(
564                "var {chunks_var} = new java.util.ArrayList<ChatCompletionChunk>();\n        var _it = {stream_var}.iterator();\n        while (_it.hasNext()) {{ {chunks_var}.add(_it.next()); }}"
565            )),
566            // PHP binding's chat_stream returns Vec<String> (each element is a
567            // JSON-serialized chunk) because ext-php-rs can't expose Rust
568            // iterators directly. Decode each element and recursively
569            // camelCase the keys so accessor chains like
570            // `$c->choices[0]->delta->finishReason` resolve against what the
571            // non-streaming PHP binding returns (camelCase getters). Three
572            // input shapes are tolerated: (a) array of JSON strings — the
573            // current binding; (b) single concatenated JSON — older binding
574            // output; (c) a real iterator — future binding upgrade.
575            "php" => Some(format!(
576                "$__camel = function ($v) use (&$__camel) {{ \
577                    if (is_array($v)) {{ \
578                        $out = []; \
579                        foreach ($v as $k => $vv) {{ \
580                            $key = is_string($k) ? lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $k)))) : $k; \
581                            $out[$key] = $__camel($vv); \
582                        }} \
583                        return (array_keys($out) === range(0, count($out) - 1)) ? $out : (object) $out; \
584                    }} \
585                    if (is_object($v)) {{ \
586                        $out = new \\stdClass(); \
587                        foreach (get_object_vars($v) as $k => $vv) {{ \
588                            $key = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $k)))); \
589                            $out->{{$key}} = $__camel($vv); \
590                        }} \
591                        return $out; \
592                    }} \
593                    return $v; \
594                }};\n        \
595                $__decode_chunk = fn($c) => $__camel(is_string($c) ? json_decode($c, true) : (is_array($c) || is_object($c) ? json_decode(json_encode($c), true) : $c));\n        \
596                ${chunks_var} = is_string(${stream_var}) \
597                    ? array_map($__decode_chunk, (array)(json_decode(${stream_var}, true) ?: [])) \
598                    : (is_array(${stream_var}) \
599                        ? array_map($__decode_chunk, ${stream_var}) \
600                        : array_map($__decode_chunk, iterator_to_array(${stream_var})));"
601            )),
602            "python" => Some(format!(
603                "{chunks_var} = []\n    async for chunk in {stream_var}:\n        {chunks_var}.append(chunk)"
604            )),
605            "kotlin" => {
606                // Kotlin: chatStream returns Iterator<ChatCompletionChunk> (from Java bridge).
607                // Drain into a Kotlin List using asSequence().toList().
608                Some(format!("val {chunks_var} = {stream_var}.asSequence().toList()"))
609            }
610            "kotlin_android" => {
611                // kotlin-android: chatStream returns Flow<ChatCompletionChunk> (kotlinx.coroutines).
612                // Collect inside a runBlocking coroutine scope using Flow.toList().
613                Some(format!("val {chunks_var} = {stream_var}.toList()"))
614            }
615            "elixir" => Some(format!("{chunks_var} = Enum.to_list({stream_var})")),
616            // WASM's chatStream returns a hand-rolled `ChatStreamIterator`
617            // struct that exposes `next()` returning `Promise<chunk | null>`,
618            // not a JS async iterable. wasm-bindgen does not auto-emit the
619            // `Symbol.asyncIterator` protocol, so `for await` on this object
620            // throws `TypeError: stream is not async iterable`. Drain via an
621            // explicit while/next() loop instead.
622            "wasm" => Some(format!(
623                "const {chunks_var}: any[] = [];\n    while (true) {{ const _chunk = await {stream_var}.next(); if (_chunk == null) break; {chunks_var}.push(_chunk); }}"
624            )),
625            "node" | "typescript" => Some(format!(
626                "const {chunks_var}: any[] = [];\n    for await (const _chunk of {stream_var}) {{ {chunks_var}.push(_chunk); }}"
627            )),
628            "swift" => {
629                // Swift's chat-stream wrapper returns AsyncThrowingStream<ChunkType, Error>,
630                // so consumers drain it with `for try await chunk in stream { ... }`. The
631                // chunk type is decoded from the bridge-boundary JSON inside the wrapper —
632                // here we just collect the typed Swift values.
633                Some(format!(
634                    "var {chunks_var}: [ChatCompletionChunk] = []\n        for try await _chunk in {stream_var} {{ {chunks_var}.append(_chunk) }}"
635                ))
636            }
637            "zig" => Some(Self::collect_snippet_zig(stream_var, chunks_var, "module", "ffi")),
638            _ => None,
639        }
640    }
641
642    /// Render Zig's streaming collect snippet using the configured module and FFI prefix.
643    pub fn collect_snippet_zig(stream_var: &str, chunks_var: &str, module_name: &str, ffi_prefix: &str) -> String {
644        let stream_next = format!("{ffi_prefix}_default_client_chat_stream_next");
645        let chunk_to_json = format!("{ffi_prefix}_chat_completion_chunk_to_json");
646        let chunk_free = format!("{ffi_prefix}_chat_completion_chunk_free");
647        let free_string = format!("{ffi_prefix}_free_string");
648
649        // Zig 0.16: ArrayList is unmanaged — no stored allocator.
650        // Use `.empty` to initialize, pass `std.heap.c_allocator` to each mutation.
651        // `stream_var` is the opaque stream handle obtained via `_start`.
652        // We collect every chunk's JSON string into `chunks_var: ArrayList([]u8)`
653        // and concatenate delta content into `{chunks_var}_content: ArrayList(u8)`.
654        // Accessors use `.items.len` and `{chunks_var}_content.items` on these lists.
655        format!(
656            concat!(
657                "var {chunks_var}: std.ArrayList([]u8) = .empty;
658",
659                "    defer {{
660",
661                "        for ({chunks_var}.items) |_cj| std.heap.c_allocator.free(_cj);
662",
663                "        {chunks_var}.deinit(std.heap.c_allocator);
664",
665                "    }}
666",
667                "    var {chunks_var}_content: std.ArrayList(u8) = .empty;
668",
669                "    defer {chunks_var}_content.deinit(std.heap.c_allocator);
670",
671                "    while (true) {{
672",
673                "        const _nc = {module_name}.c.{stream_next}({stream_var});
674",
675                "        if (_nc == null) break;
676",
677                "        const _np = {module_name}.c.{chunk_to_json}(_nc);
678",
679                "        {module_name}.c.{chunk_free}(_nc);
680",
681                "        if (_np == null) continue;
682",
683                "        const _ns = std.mem.span(_np);
684",
685                "        const _nj = try std.heap.c_allocator.dupe(u8, _ns);
686",
687                "        {module_name}.c.{free_string}(_np);
688",
689                "        if (std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, _nj, .{{}})) |_cp| {{
690",
691                "            defer _cp.deinit();
692",
693                "            if (_cp.value.object.get(\"choices\")) |_chs|
694",
695                "                if (_chs.array.items.len > 0)
696",
697                "                    if (_chs.array.items[0].object.get(\"delta\")) |_dl|
698",
699                "                        if (_dl.object.get(\"content\")) |_ct|
700",
701                "                            if (_ct == .string) try {chunks_var}_content.appendSlice(std.heap.c_allocator, _ct.string);
702",
703                "        }} else |_| {{}}
704",
705                "        try {chunks_var}.append(std.heap.c_allocator, _nj);
706",
707                "    }}"
708            ),
709            chunks_var = chunks_var,
710            stream_var = stream_var,
711            module_name = module_name,
712            stream_next = stream_next,
713            chunk_to_json = chunk_to_json,
714            chunk_free = chunk_free,
715            free_string = free_string,
716        )
717    }
718}
719
720/// Identifies a `CrawlEvent` variant for `stream.has_*_event` accessors.
721#[derive(Debug, Clone, Copy)]
722enum EventVariant {
723    Page,
724    Error,
725    Complete,
726}
727
728impl EventVariant {
729    /// Lower-case JSON-wire tag value for the `type` discriminator.
730    fn tag(self) -> &'static str {
731        match self {
732            EventVariant::Page => "page",
733            EventVariant::Error => "error",
734            EventVariant::Complete => "complete",
735        }
736    }
737
738    /// Upper-camel variant name as used in most language bindings
739    /// (e.g. `CrawlEvent_Page`, `CrawlEventPage`, `CrawlEvent.Page`).
740    fn upper_camel(self) -> &'static str {
741        match self {
742            EventVariant::Page => "Page",
743            EventVariant::Error => "Error",
744            EventVariant::Complete => "Complete",
745        }
746    }
747}
748
749/// Emit a language-native boolean expression that is `true` iff any chunk in
750/// `chunks_var` matches the given `CrawlEvent` variant.
751///
752/// Returns `None` for languages where streaming `CrawlEvent` matching is not
753/// expressible (PHP — eager-JSON, WASM — no streaming on wasm32).
754fn has_event_variant_accessor(lang: &str, chunks_var: &str, variant: EventVariant) -> Option<String> {
755    let tag = variant.tag();
756    let camel = variant.upper_camel();
757    match lang {
758        // Python: CrawlEvent exposes `.type` returning the lower-case wire tag.
759        "python" => Some(format!("any(e.type == \"{tag}\" for e in {chunks_var})")),
760        // Node / TypeScript / WASM JS: deserialized CrawlEvent objects expose
761        // a `type` discriminator field with the lower-case wire tag.
762        "node" | "typescript" => Some(format!("{chunks_var}.some((e: any) => e?.type === \"{tag}\")")),
763        // Ruby: each variant class exposes `<tag>?` predicates.
764        "ruby" => Some(format!("{chunks_var}.any? {{ |e| e.{tag}? }}")),
765        // Go: variants are concrete struct types (CrawlEventPage, etc.) that
766        // implement the `CrawlEvent` interface.  Use a type switch via an
767        // anonymous IIFE so the accessor remains an expression.
768        "go" => Some(format!(
769            "func() bool {{ for _, e := range {chunks_var} {{ if _, _ok := e.(pkg.CrawlEvent{camel}); _ok {{ return true }} }}; return false }}()"
770        )),
771        // Java: sealed interface CrawlEvent with nested records (Page/Error/Complete).
772        "java" => Some(format!(
773            "{chunks_var}.stream().anyMatch(e -> e instanceof CrawlEvent.{camel})"
774        )),
775        // C#: abstract record CrawlEvent with nested sealed records.
776        "csharp" => Some(format!(
777            "{chunks_var}.Any(e => e is global::Kreuzcrawl.CrawlEvent.{camel})"
778        )),
779        // Swift: enum CrawlEvent with associated values.  `if case .<tag> = e`
780        // is a statement, not an expression — wrap in a `contains(where:)` call
781        // with a switch-returning-bool closure.
782        "swift" => Some(format!(
783            "{chunks_var}.contains(where: {{ e in if case .{tag} = e {{ return true }} else {{ return false }} }})"
784        )),
785        // Elixir: each event is a map with a `:type` atom discriminator.
786        "elixir" => Some(format!(
787            "Enum.any?({chunks_var}, fn e -> Map.get(e, :type) == :{tag} end)"
788        )),
789        // Kotlin (Java records via typealias): same shape as Java.
790        "kotlin" => Some(format!("{chunks_var}.any {{ it is CrawlEvent.{camel} }}")),
791        // kotlin-android: native sealed class with the same nested variants.
792        "kotlin_android" => Some(format!("{chunks_var}.any {{ it is CrawlEvent.{camel} }}")),
793        // Dart (freezed): variants are CrawlEvent_Page / CrawlEvent_Error /
794        // CrawlEvent_Complete (underscored).
795        "dart" => Some(format!("{chunks_var}.any((e) => e is CrawlEvent_{camel})")),
796        // Zig: collected chunks are JSON strings (see Zig collect_snippet); check
797        // for the wire-format `"type":"<tag>"` substring on any item.  Substring
798        // matching is safe because the JSON is produced by the FFI marshaller
799        // with a fixed key ordering and the tag values do not collide.
800        "zig" => Some(format!(
801            "blk: {{ for ({chunks_var}.items) |_e| {{ if (std.mem.indexOf(u8, _e, \"\\\"type\\\":\\\"{tag}\\\"\") != null) break :blk true; }} break :blk false; }}"
802        )),
803        // Rust: CrawlEvent is a tagged enum (`#[serde(tag = "type")]`) with
804        // struct-style variants `Page { result }`, `Error { url, error }`,
805        // `Complete { pages_crawled }`.  Use `matches!` for the predicate so
806        // we don't bind the variant payload.
807        "rust" => Some(format!(
808            "{chunks_var}.iter().any(|e| matches!(e, kreuzcrawl::CrawlEvent::{camel} {{ .. }}))"
809        )),
810        // PHP: crawl-stream is delivered as eager JSON (see PHP collect_snippet)
811        // and the kreuzcrawl PHP binding does not expose typed CrawlEvent objects.
812        // WASM: streaming is unavailable on wasm32 targets.
813        "php" | "wasm" => None,
814        _ => None,
815    }
816}
817
818/// Render a Swift deep accessor for `tool_calls[N]...` paths.
819///
820/// The flat tool_calls array is `[StreamToolCallRef]`.  Each element is a
821/// swift-bridge opaque ref: the first field after an index (e.g. `function`)
822/// is accessed with `.method()` (direct call on the non-optional ref).
823/// All subsequent fields use `?.method()` (optional chaining) because each
824/// intermediate method returns `Optional`.  The string leaf appends
825/// `?.toString()` to convert `RustString?` to `String?`.
826///
827/// Example: `[0].function.name`
828///   → `(root)[0].function()?.name()?.toString()`
829fn render_swift_tool_calls_deep(root_expr: &str, tail: &str) -> String {
830    use heck::ToLowerCamelCase;
831    let segs = parse_tail(tail);
832    let mut expr = root_expr.to_string();
833    let last_field_idx = segs.iter().rposition(|s| matches!(s, TailSeg::Field(_)));
834    // Track whether the previous segment was an index (non-optional element),
835    // which means the next field uses `.method()` not `?.method()`.
836    let mut prev_was_index = false;
837
838    for (i, seg) in segs.iter().enumerate() {
839        match seg {
840            TailSeg::Index(n) => {
841                expr = format!("({expr})[{n}]");
842                prev_was_index = true;
843            }
844            TailSeg::Field(f) => {
845                let method = f.to_lower_camel_case();
846                let is_last = Some(i) == last_field_idx;
847                let chain = if prev_was_index { "." } else { "?." };
848                if is_last {
849                    // Leaf: string field — call + optional-chain .toString()
850                    expr = format!("{expr}{chain}{method}()?.toString()");
851                } else {
852                    // Intermediate object field
853                    expr = format!("{expr}{chain}{method}()");
854                }
855                prev_was_index = false;
856            }
857        }
858    }
859    expr
860}
861
862/// Render a rust deep accessor for `tool_calls[N]...` paths over the flattened
863/// stream-chunk tool_calls iterator. Handles Option-wrapped fields by chaining
864/// `as_ref().and_then(...)` so the final value is a `&str` (for name/id/arguments).
865fn render_rust_tool_calls_deep(chunks_var: &str, tail: &str) -> String {
866    let segs = parse_tail(tail);
867    // Locate index segment (rust uses .nth(n) on the iterator instead of [N] on a Vec)
868    let idx = segs.iter().find_map(|s| match s {
869        TailSeg::Index(n) => Some(*n),
870        _ => None,
871    });
872    let field_segs: Vec<&str> = segs
873        .iter()
874        .filter_map(|s| match s {
875            TailSeg::Field(f) => Some(f.as_str()),
876            _ => None,
877        })
878        .collect();
879
880    let base = format!(
881        "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten()))"
882    );
883    let with_nth = match idx {
884        Some(n) => format!("{base}.nth({n})"),
885        None => base,
886    };
887
888    // Chain Option-aware field access. Every field on StreamToolCall is Option<...>;
889    // the leaf (String fields) uses `.as_deref()` to project to `&str`.
890    let mut expr = with_nth;
891    for (i, f) in field_segs.iter().enumerate() {
892        let is_leaf = i == field_segs.len() - 1;
893        if is_leaf {
894            expr = format!("{expr}.and_then(|x| x.{f}.as_deref())");
895        } else {
896            expr = format!("{expr}.and_then(|x| x.{f}.as_ref())");
897        }
898    }
899    format!("{expr}.unwrap_or(\"\")")
900}
901
902/// Parse a deep-path tail (e.g. `[0].function.name`) into structured segments.
903///
904/// The tail always starts with either `[N]` (array index) or `.field`.
905/// Returns a list of segments: `TailSeg::Index(N)` or `TailSeg::Field(name)`.
906#[derive(Debug, PartialEq)]
907enum TailSeg {
908    Index(usize),
909    Field(String),
910}
911
912fn parse_tail(tail: &str) -> Vec<TailSeg> {
913    let mut segs = Vec::new();
914    let mut rest = tail;
915    while !rest.is_empty() {
916        if let Some(inner) = rest.strip_prefix('[') {
917            // Array index: `[N]`
918            if let Some(close) = inner.find(']') {
919                let idx_str = &inner[..close];
920                if let Ok(idx) = idx_str.parse::<usize>() {
921                    segs.push(TailSeg::Index(idx));
922                }
923                rest = &inner[close + 1..];
924            } else {
925                break;
926            }
927        } else if let Some(inner) = rest.strip_prefix('.') {
928            // Field name: up to next `.` or `[`
929            let end = inner.find(['.', '[']).unwrap_or(inner.len());
930            segs.push(TailSeg::Field(inner[..end].to_string()));
931            rest = &inner[end..];
932        } else {
933            break;
934        }
935    }
936    segs
937}
938
939/// Render the full deep accessor expression by appending per-language tail
940/// segments onto `root_expr`.
941fn render_deep_tail(root_expr: &str, tail: &str, lang: &str) -> String {
942    use heck::{ToLowerCamelCase, ToPascalCase};
943
944    let segs = parse_tail(tail);
945    let mut out = root_expr.to_string();
946
947    for seg in &segs {
948        match (seg, lang) {
949            (TailSeg::Index(n), "rust") => {
950                out = format!("({out})[{n}]");
951            }
952            (TailSeg::Index(n), "java") => {
953                out = format!("({out}).get({n})");
954            }
955            (TailSeg::Index(n), "kotlin") => {
956                if *n == 0 {
957                    out = format!("({out}).first()");
958                } else {
959                    out = format!("({out}).get({n})");
960                }
961            }
962            (TailSeg::Index(n), "kotlin_android") => {
963                if *n == 0 {
964                    out = format!("({out}).first()");
965                } else {
966                    out = format!("({out})[{n}]");
967                }
968            }
969            (TailSeg::Index(n), "elixir") => {
970                out = format!("Enum.at({out}, {n})");
971            }
972            (TailSeg::Index(n), "zig") => {
973                out = format!("({out}).items[{n}]");
974            }
975            (TailSeg::Index(n), "php") => {
976                out = format!("({out})[{n}]");
977            }
978            (TailSeg::Index(n), _) => {
979                // rust-like for go (but we handle Field differently), python, node, ts, kotlin, etc.
980                out = format!("({out})[{n}]");
981            }
982            (TailSeg::Field(f), "rust") => {
983                use heck::ToSnakeCase;
984                out.push('.');
985                out.push_str(&f.to_snake_case());
986            }
987            (TailSeg::Field(f), "go") => {
988                use alef_codegen::naming::to_go_name;
989                out.push('.');
990                out.push_str(&to_go_name(f));
991            }
992            (TailSeg::Field(f), "java") => {
993                out.push('.');
994                out.push_str(&f.to_lower_camel_case());
995                out.push_str("()");
996            }
997            (TailSeg::Field(f), "kotlin") => {
998                // Use safe-call `?.` for all field accessors in Kotlin deep paths.
999                // All streaming tool-call sub-fields (`function`, `id`, `name`,
1000                // `arguments`) are nullable in the generated Java records, so `?.`
1001                // is always correct here and prevents "non-null asserted call on
1002                // nullable receiver" compile errors.
1003                out.push_str("?.");
1004                out.push_str(&f.to_lower_camel_case());
1005                out.push_str("()");
1006            }
1007            (TailSeg::Field(f), "kotlin_android") => {
1008                // kotlin-android: Kotlin data classes use property access (no parens).
1009                out.push_str("?.");
1010                out.push_str(&f.to_lower_camel_case());
1011            }
1012            (TailSeg::Field(f), "csharp") => {
1013                out.push('.');
1014                out.push_str(&f.to_pascal_case());
1015            }
1016            (TailSeg::Field(f), "php") => {
1017                // Streaming PHP accessors operate on json_decoded stdClass with
1018                // snake_case property names (JSON wire format), not the camelCase
1019                // properties exposed on the PHP wrapper class. Use the raw field
1020                // name verbatim.
1021                out.push_str("->");
1022                out.push_str(f);
1023            }
1024            (TailSeg::Field(f), "elixir") => {
1025                out.push('.');
1026                out.push_str(f);
1027            }
1028            (TailSeg::Field(f), "zig") => {
1029                out.push('.');
1030                out.push_str(f);
1031            }
1032            (TailSeg::Field(f), "python") | (TailSeg::Field(f), "ruby") => {
1033                out.push('.');
1034                out.push_str(f);
1035            }
1036            // node, wasm, typescript, kotlin, dart, swift all use camelCase
1037            (TailSeg::Field(f), _) => {
1038                out.push('.');
1039                out.push_str(&f.to_lower_camel_case());
1040            }
1041        }
1042    }
1043
1044    out
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050
1051    #[test]
1052    fn is_streaming_virtual_field_recognizes_all_fields() {
1053        for field in STREAMING_VIRTUAL_FIELDS {
1054            assert!(
1055                is_streaming_virtual_field(field),
1056                "field '{field}' not recognized as streaming virtual"
1057            );
1058        }
1059    }
1060
1061    #[test]
1062    fn is_streaming_virtual_field_rejects_real_fields() {
1063        assert!(!is_streaming_virtual_field("content"));
1064        assert!(!is_streaming_virtual_field("choices"));
1065        assert!(!is_streaming_virtual_field("model"));
1066        assert!(!is_streaming_virtual_field(""));
1067    }
1068
1069    #[test]
1070    fn is_streaming_virtual_field_rejects_non_root_paths_with_matching_tail() {
1071        // Regression: prior impl matched any field whose chars-after-root-len started
1072        // with `[` or `.` — without checking that the field actually starts with the
1073        // root token. `choices[0].finish_reason` therefore falsely matched root
1074        // `tool_calls` because byte 10 onward is `.finish_reason`.
1075        assert!(!is_streaming_virtual_field("choices[0].finish_reason"));
1076        assert!(!is_streaming_virtual_field("choices[0].message.content"));
1077        assert!(!is_streaming_virtual_field("data[0].embedding"));
1078    }
1079
1080    #[test]
1081    fn is_streaming_virtual_field_does_not_match_usage() {
1082        // `usage` is intentionally NOT a streaming-virtual root: chat/embed
1083        // responses carry `usage.total_tokens` at the response root, so treating
1084        // it as virtual would drag non-streaming tests into the chunks accessor.
1085        assert!(!is_streaming_virtual_field("usage"));
1086        assert!(!is_streaming_virtual_field("usage.total_tokens"));
1087        assert!(!is_streaming_virtual_field("usage.prompt_tokens"));
1088    }
1089
1090    #[test]
1091    fn accessor_chunks_returns_var_name() {
1092        assert_eq!(
1093            StreamingFieldResolver::accessor("chunks", "rust", "chunks"),
1094            Some("chunks".to_string())
1095        );
1096        assert_eq!(
1097            StreamingFieldResolver::accessor("chunks", "node", "chunks"),
1098            Some("chunks".to_string())
1099        );
1100    }
1101
1102    #[test]
1103    fn accessor_chunks_length_uses_language_idiom() {
1104        let rust = StreamingFieldResolver::accessor("chunks.length", "rust", "chunks").unwrap();
1105        assert!(rust.contains(".len()"), "rust: {rust}");
1106
1107        let go = StreamingFieldResolver::accessor("chunks.length", "go", "chunks").unwrap();
1108        assert!(go.starts_with("len("), "go: {go}");
1109
1110        let node = StreamingFieldResolver::accessor("chunks.length", "node", "chunks").unwrap();
1111        assert!(node.contains(".length"), "node: {node}");
1112
1113        let php = StreamingFieldResolver::accessor("chunks.length", "php", "chunks").unwrap();
1114        assert!(php.starts_with("count("), "php: {php}");
1115    }
1116
1117    #[test]
1118    fn accessor_chunks_length_zig_uses_items_len() {
1119        let zig = StreamingFieldResolver::accessor("chunks.length", "zig", "chunks").unwrap();
1120        assert_eq!(zig, "chunks.items.len", "zig chunks.length: {zig}");
1121    }
1122
1123    #[test]
1124    fn accessor_stream_content_zig_uses_content_items() {
1125        let zig = StreamingFieldResolver::accessor("stream_content", "zig", "chunks").unwrap();
1126        assert_eq!(zig, "chunks_content.items", "zig stream_content: {zig}");
1127    }
1128
1129    #[test]
1130    fn collect_snippet_zig_drains_via_ffi() {
1131        let snip = StreamingFieldResolver::collect_snippet("zig", "_stream_handle", "chunks").unwrap();
1132        assert!(snip.contains("std.ArrayList([]u8)"), "zig collect: {snip}");
1133        assert!(snip.contains("chat_stream_next(_stream_handle)"), "zig collect: {snip}");
1134        assert!(snip.contains("chunks_content"), "zig collect: {snip}");
1135        assert!(
1136            snip.contains("chunks.append(std.heap.c_allocator"),
1137            "zig collect: {snip}"
1138        );
1139        assert!(snip.contains(".empty;"), "zig collect (Zig 0.16 unmanaged): {snip}");
1140    }
1141
1142    #[test]
1143    fn accessor_stream_content_rust_uses_iterator() {
1144        let expr = StreamingFieldResolver::accessor("stream_content", "rust", "chunks").unwrap();
1145        assert!(expr.contains(".collect::<String>()"), "rust stream_content: {expr}");
1146    }
1147
1148    #[test]
1149    fn accessor_no_chunks_after_done_returns_true() {
1150        for lang in ["rust", "go", "java", "php", "node", "wasm", "elixir"] {
1151            let expr = StreamingFieldResolver::accessor("no_chunks_after_done", lang, "chunks").unwrap();
1152            assert_eq!(expr, "true", "lang {lang}: expected 'true', got '{expr}'");
1153        }
1154    }
1155
1156    #[test]
1157    fn accessor_elixir_chunks_length_uses_length_function() {
1158        let expr = StreamingFieldResolver::accessor("chunks.length", "elixir", "chunks").unwrap();
1159        assert_eq!(expr, "length(chunks)", "elixir chunks.length: {expr}");
1160    }
1161
1162    #[test]
1163    fn accessor_elixir_stream_content_uses_pipe() {
1164        let expr = StreamingFieldResolver::accessor("stream_content", "elixir", "chunks").unwrap();
1165        assert!(expr.contains("|> Enum.join"), "elixir stream_content: {expr}");
1166        assert!(expr.contains("|> Enum.map"), "elixir stream_content: {expr}");
1167        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
1168        assert!(
1169            !expr.contains("choices[0]"),
1170            "elixir stream_content must not use bracket access on list: {expr}"
1171        );
1172        assert!(
1173            expr.contains("Enum.at("),
1174            "elixir stream_content must use Enum.at for list index: {expr}"
1175        );
1176    }
1177
1178    #[test]
1179    fn accessor_elixir_stream_complete_uses_list_last() {
1180        let expr = StreamingFieldResolver::accessor("stream_complete", "elixir", "chunks").unwrap();
1181        assert!(expr.contains("List.last(chunks)"), "elixir stream_complete: {expr}");
1182        assert!(expr.contains("finish_reason != nil"), "elixir stream_complete: {expr}");
1183        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
1184        assert!(
1185            !expr.contains("choices[0]"),
1186            "elixir stream_complete must not use bracket access on list: {expr}"
1187        );
1188        assert!(
1189            expr.contains("Enum.at("),
1190            "elixir stream_complete must use Enum.at for list index: {expr}"
1191        );
1192    }
1193
1194    #[test]
1195    fn accessor_elixir_finish_reason_uses_list_last() {
1196        let expr = StreamingFieldResolver::accessor("finish_reason", "elixir", "chunks").unwrap();
1197        assert!(expr.contains("List.last(chunks)"), "elixir finish_reason: {expr}");
1198        assert!(expr.contains("finish_reason"), "elixir finish_reason: {expr}");
1199        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
1200        assert!(
1201            !expr.contains("choices[0]"),
1202            "elixir finish_reason must not use bracket access on list: {expr}"
1203        );
1204        assert!(
1205            expr.contains("Enum.at("),
1206            "elixir finish_reason must use Enum.at for list index: {expr}"
1207        );
1208    }
1209
1210    #[test]
1211    fn collect_snippet_elixir_uses_enum_to_list() {
1212        let snip = StreamingFieldResolver::collect_snippet("elixir", "result", "chunks").unwrap();
1213        assert!(snip.contains("Enum.to_list(result)"), "elixir: {snip}");
1214        assert!(snip.contains("chunks ="), "elixir: {snip}");
1215    }
1216
1217    #[test]
1218    fn collect_snippet_rust_uses_tokio_stream() {
1219        let snip = StreamingFieldResolver::collect_snippet("rust", "result", "chunks").unwrap();
1220        assert!(snip.contains("tokio_stream::StreamExt::collect"), "rust: {snip}");
1221        assert!(snip.contains("let chunks"), "rust: {snip}");
1222        // Items are Result<ChatCompletionChunk, _> — unwrap so chunks is Vec<ChatCompletionChunk>
1223        assert!(snip.contains(".expect("), "rust must unwrap Result items: {snip}");
1224    }
1225
1226    #[test]
1227    fn collect_snippet_go_drains_channel() {
1228        let snip = StreamingFieldResolver::collect_snippet("go", "stream", "chunks").unwrap();
1229        assert!(snip.contains("for chunk := range stream"), "go: {snip}");
1230    }
1231
1232    #[test]
1233    fn collect_snippet_java_uses_iterator() {
1234        let snip = StreamingFieldResolver::collect_snippet("java", "result", "chunks").unwrap();
1235        // Must call .iterator() on the Stream<T> before using hasNext()/next() —
1236        // Stream does not implement those methods directly.
1237        assert!(
1238            snip.contains(".iterator()"),
1239            "java snippet must call .iterator() on stream: {snip}"
1240        );
1241        assert!(snip.contains("hasNext()"), "java: {snip}");
1242        assert!(snip.contains(".next()"), "java: {snip}");
1243    }
1244
1245    #[test]
1246    fn collect_snippet_php_decodes_json_or_iterates() {
1247        let snip = StreamingFieldResolver::collect_snippet("php", "result", "chunks").unwrap();
1248        // PHP binding's chat_stream_async returns a JSON string today; collect-snippet
1249        // decodes it.  iterator_to_array is retained as the fallback branch so a
1250        // future binding that exposes a real iterator continues to work without
1251        // regenerating the e2e tests.
1252        assert!(snip.contains("json_decode"), "php must decode JSON: {snip}");
1253        assert!(
1254            snip.contains("iterator_to_array"),
1255            "php must keep iterator_to_array fallback: {snip}"
1256        );
1257        assert!(snip.contains("$chunks ="), "php must bind $chunks: {snip}");
1258    }
1259
1260    #[test]
1261    fn collect_snippet_node_uses_for_await() {
1262        let snip = StreamingFieldResolver::collect_snippet("node", "result", "chunks").unwrap();
1263        assert!(snip.contains("for await"), "node: {snip}");
1264    }
1265
1266    #[test]
1267    fn collect_snippet_python_uses_async_for() {
1268        let snip = StreamingFieldResolver::collect_snippet("python", "result", "chunks").unwrap();
1269        assert!(snip.contains("async for chunk in result"), "python: {snip}");
1270        assert!(snip.contains("chunks.append(chunk)"), "python: {snip}");
1271    }
1272
1273    #[test]
1274    fn accessor_stream_content_python_uses_join() {
1275        let expr = StreamingFieldResolver::accessor("stream_content", "python", "chunks").unwrap();
1276        assert!(expr.contains("\"\".join("), "python stream_content: {expr}");
1277        assert!(expr.contains("c.choices"), "python stream_content: {expr}");
1278    }
1279
1280    #[test]
1281    fn accessor_stream_complete_python_uses_finish_reason() {
1282        let expr = StreamingFieldResolver::accessor("stream_complete", "python", "chunks").unwrap();
1283        assert!(
1284            expr.contains("finish_reason is not None"),
1285            "python stream_complete: {expr}"
1286        );
1287    }
1288
1289    #[test]
1290    fn accessor_finish_reason_python_uses_last_chunk() {
1291        let expr = StreamingFieldResolver::accessor("finish_reason", "python", "chunks").unwrap();
1292        assert!(expr.contains("chunks[-1]"), "python finish_reason: {expr}");
1293        // Must wrap in str() so FinishReason enum objects support .strip() comparisons
1294        assert!(
1295            expr.starts_with("(str(") || expr.contains("str(chunks"),
1296            "python finish_reason must wrap in str(): {expr}"
1297        );
1298    }
1299
1300    #[test]
1301    fn accessor_tool_calls_python_uses_list_comprehension() {
1302        let expr = StreamingFieldResolver::accessor("tool_calls", "python", "chunks").unwrap();
1303        assert!(expr.contains("for c in chunks"), "python tool_calls: {expr}");
1304        assert!(expr.contains("tool_calls"), "python tool_calls: {expr}");
1305    }
1306
1307    #[test]
1308    fn accessor_usage_python_uses_last_chunk() {
1309        let expr = StreamingFieldResolver::accessor("usage", "python", "chunks").unwrap();
1310        assert!(
1311            expr.contains("chunks[-1].usage"),
1312            "python usage: expected chunks[-1].usage, got: {expr}"
1313        );
1314    }
1315
1316    #[test]
1317    fn accessor_usage_total_tokens_does_not_route_via_chunks() {
1318        // `usage` is intentionally NOT a streaming-virtual root (it overlaps the
1319        // non-streaming response shape). The accessor must return None so the
1320        // assertion falls through to the normal field-path codegen.
1321        assert!(StreamingFieldResolver::accessor("usage.total_tokens", "python", "chunks").is_none());
1322    }
1323
1324    #[test]
1325    fn accessor_unknown_field_returns_none() {
1326        assert_eq!(
1327            StreamingFieldResolver::accessor("nonexistent_field", "rust", "chunks"),
1328            None
1329        );
1330    }
1331
1332    // -----------------------------------------------------------------------
1333    // Deep-path tests: tool_calls[0].function.name and tool_calls[0].id
1334    // -----------------------------------------------------------------------
1335
1336    #[test]
1337    fn is_streaming_virtual_field_recognizes_deep_tool_calls_paths() {
1338        assert!(
1339            is_streaming_virtual_field("tool_calls[0].function.name"),
1340            "tool_calls[0].function.name should be recognized"
1341        );
1342        assert!(
1343            is_streaming_virtual_field("tool_calls[0].id"),
1344            "tool_calls[0].id should be recognized"
1345        );
1346        assert!(
1347            is_streaming_virtual_field("tool_calls[1].function.arguments"),
1348            "tool_calls[1].function.arguments should be recognized"
1349        );
1350        // bare root still recognized
1351        assert!(is_streaming_virtual_field("tool_calls"));
1352        // unrelated deep path must NOT be recognized
1353        assert!(!is_streaming_virtual_field("tool_calls_extra.name"));
1354        assert!(!is_streaming_virtual_field("nonexistent[0].field"));
1355    }
1356
1357    /// Snapshot: `tool_calls[0].function.name` for Rust, Kotlin, TypeScript.
1358    ///
1359    /// These three languages cover the main accessor styles:
1360    /// - Rust: snake_case field, explicit `[0]` index on collected Vec
1361    /// - Kotlin: camelCase method calls with `.first()` for index 0
1362    /// - TypeScript/Node: camelCase properties with `[0]` bracket
1363    #[test]
1364    fn deep_tool_calls_function_name_snapshot_rust_kotlin_ts() {
1365        let field = "tool_calls[0].function.name";
1366
1367        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1368        // Rust: Option-aware chain over the iterator — `.nth(0)` then `.and_then`
1369        // on each Option-wrapped field (function is Option<StreamFunctionCall>,
1370        // name is Option<String>). Final `.unwrap_or("")` yields `&str`.
1371        assert!(
1372            rust.contains(".nth(0)"),
1373            "rust deep tool_calls: expected .nth(0) iterator index, got: {rust}"
1374        );
1375        assert!(
1376            rust.contains("x.function.as_ref()"),
1377            "rust deep tool_calls: expected Option-aware function access, got: {rust}"
1378        );
1379        assert!(
1380            rust.contains("x.name.as_deref()"),
1381            "rust deep tool_calls: expected Option-aware name leaf, got: {rust}"
1382        );
1383        assert!(
1384            !rust.contains("// skipped"),
1385            "rust deep tool_calls: must not emit skip comment, got: {rust}"
1386        );
1387
1388        let kotlin = StreamingFieldResolver::accessor(field, "kotlin", "chunks").unwrap();
1389        // Kotlin: uses .first() for index 0, then .function().name()
1390        assert!(
1391            kotlin.contains(".first()"),
1392            "kotlin deep tool_calls: expected .first() for index 0, got: {kotlin}"
1393        );
1394        assert!(
1395            kotlin.contains(".function()"),
1396            "kotlin deep tool_calls: expected .function() method call, got: {kotlin}"
1397        );
1398        assert!(
1399            kotlin.contains(".name()"),
1400            "kotlin deep tool_calls: expected .name() method call, got: {kotlin}"
1401        );
1402
1403        let ts = StreamingFieldResolver::accessor(field, "node", "chunks").unwrap();
1404        // TypeScript/Node: uses [0] then .function.name (camelCase)
1405        assert!(
1406            ts.contains("[0]"),
1407            "ts/node deep tool_calls: expected [0] index, got: {ts}"
1408        );
1409        assert!(
1410            ts.contains(".function"),
1411            "ts/node deep tool_calls: expected .function segment, got: {ts}"
1412        );
1413        assert!(
1414            ts.contains(".name"),
1415            "ts/node deep tool_calls: expected .name segment, got: {ts}"
1416        );
1417    }
1418
1419    #[test]
1420    fn deep_tool_calls_id_snapshot_all_langs() {
1421        let field = "tool_calls[0].id";
1422
1423        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1424        assert!(rust.contains(".nth(0)"), "rust: {rust}");
1425        assert!(rust.contains("x.id.as_deref()"), "rust: {rust}");
1426
1427        let go = StreamingFieldResolver::accessor(field, "go", "chunks").unwrap();
1428        assert!(go.contains("[0]"), "go: {go}");
1429        // Go: ID is a well-known initialism → uppercase
1430        assert!(go.contains(".ID"), "go: expected .ID initialism, got: {go}");
1431
1432        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1433        assert!(python.contains("[0]"), "python: {python}");
1434        assert!(python.contains(".id"), "python: {python}");
1435
1436        let php = StreamingFieldResolver::accessor(field, "php", "chunks").unwrap();
1437        assert!(php.contains("[0]"), "php: {php}");
1438        assert!(php.contains("->id"), "php: expected ->id, got: {php}");
1439
1440        let java = StreamingFieldResolver::accessor(field, "java", "chunks").unwrap();
1441        assert!(java.contains(".get(0)"), "java: expected .get(0), got: {java}");
1442        assert!(java.contains(".id()"), "java: expected .id() method call, got: {java}");
1443
1444        let csharp = StreamingFieldResolver::accessor(field, "csharp", "chunks").unwrap();
1445        assert!(csharp.contains("[0]"), "csharp: {csharp}");
1446        assert!(
1447            csharp.contains(".Id"),
1448            "csharp: expected .Id (PascalCase), got: {csharp}"
1449        );
1450
1451        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1452        assert!(elixir.contains("Enum.at("), "elixir: expected Enum.at(, got: {elixir}");
1453        assert!(elixir.contains(".id"), "elixir: {elixir}");
1454    }
1455
1456    #[test]
1457    fn deep_tool_calls_function_name_snapshot_python_elixir_zig() {
1458        let field = "tool_calls[0].function.name";
1459
1460        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1461        assert!(python.contains("[0]"), "python: {python}");
1462        assert!(python.contains(".function"), "python: {python}");
1463        assert!(python.contains(".name"), "python: {python}");
1464
1465        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1466        // Elixir: Enum.at(…, 0).function.name
1467        assert!(elixir.contains("Enum.at("), "elixir: {elixir}");
1468        assert!(elixir.contains(".function"), "elixir: {elixir}");
1469        assert!(elixir.contains(".name"), "elixir: {elixir}");
1470
1471        // Zig stores chunks as JSON strings, not typed records — deep
1472        // tool_calls paths are unsupported and resolve to None so the
1473        // assertion site can skip them.
1474        assert!(
1475            StreamingFieldResolver::accessor(field, "zig", "chunks").is_none(),
1476            "zig: expected None for deep tool_calls path"
1477        );
1478    }
1479
1480    #[test]
1481    fn parse_tail_parses_index_then_field_segments() {
1482        let segs = parse_tail("[0].function.name");
1483        assert_eq!(segs.len(), 3, "expected 3 segments, got: {segs:?}");
1484        assert_eq!(segs[0], TailSeg::Index(0));
1485        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1486        assert_eq!(segs[2], TailSeg::Field("name".to_string()));
1487    }
1488
1489    #[test]
1490    fn parse_tail_parses_simple_index_field() {
1491        let segs = parse_tail("[0].id");
1492        assert_eq!(segs.len(), 2, "expected 2 segments, got: {segs:?}");
1493        assert_eq!(segs[0], TailSeg::Index(0));
1494        assert_eq!(segs[1], TailSeg::Field("id".to_string()));
1495    }
1496
1497    #[test]
1498    fn parse_tail_handles_nonzero_index() {
1499        let segs = parse_tail("[2].function.arguments");
1500        assert_eq!(segs[0], TailSeg::Index(2));
1501        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1502        assert_eq!(segs[2], TailSeg::Field("arguments".to_string()));
1503    }
1504
1505    // -----------------------------------------------------------------------
1506    // Swift-specific accessor tests
1507    // -----------------------------------------------------------------------
1508
1509    #[test]
1510    fn accessor_chunks_length_swift_uses_count() {
1511        let swift = StreamingFieldResolver::accessor("chunks.length", "swift", "chunks").unwrap();
1512        assert_eq!(swift, "chunks.count", "swift chunks.length: {swift}");
1513    }
1514
1515    #[test]
1516    fn accessor_stream_content_swift_uses_swift_closures() {
1517        let expr = StreamingFieldResolver::accessor("stream_content", "swift", "chunks").unwrap();
1518        // Must use Swift closure syntax (`{ ... }`) not JS arrow (`=>`)
1519        assert!(
1520            expr.contains("{ c in"),
1521            "swift stream_content must use Swift closure syntax, got: {expr}"
1522        );
1523        assert!(
1524            !expr.contains("=>"),
1525            "swift stream_content must not contain JS arrow `=>`, got: {expr}"
1526        );
1527        // Content is accessed via method call chains
1528        assert!(
1529            expr.contains("choices()"),
1530            "swift stream_content must use .choices() method call, got: {expr}"
1531        );
1532        assert!(
1533            expr.contains("delta()"),
1534            "swift stream_content must use .delta() method call, got: {expr}"
1535        );
1536        assert!(
1537            expr.contains("content()"),
1538            "swift stream_content must use .content() method call, got: {expr}"
1539        );
1540        assert!(
1541            expr.contains(".toString()"),
1542            "swift stream_content must convert RustString via .toString(), got: {expr}"
1543        );
1544        assert!(
1545            expr.contains(".joined()"),
1546            "swift stream_content must join with .joined(), got: {expr}"
1547        );
1548        // Must not use JS .length or .join('')
1549        assert!(
1550            !expr.contains(".length"),
1551            "swift stream_content must not use JS .length, got: {expr}"
1552        );
1553        assert!(
1554            !expr.contains(".join("),
1555            "swift stream_content must not use JS .join(, got: {expr}"
1556        );
1557    }
1558
1559    #[test]
1560    fn accessor_stream_complete_swift_uses_swift_syntax() {
1561        let expr = StreamingFieldResolver::accessor("stream_complete", "swift", "chunks").unwrap();
1562        // Must use Swift isEmpty / last! syntax, not JS .length
1563        assert!(
1564            expr.contains("isEmpty"),
1565            "swift stream_complete must use .isEmpty, got: {expr}"
1566        );
1567        assert!(
1568            expr.contains(".last!"),
1569            "swift stream_complete must use .last!, got: {expr}"
1570        );
1571        assert!(
1572            expr.contains("choices()"),
1573            "swift stream_complete must use .choices() method call, got: {expr}"
1574        );
1575        assert!(
1576            expr.contains("finish_reason()"),
1577            "swift stream_complete must use .finish_reason(), got: {expr}"
1578        );
1579        assert!(
1580            !expr.contains(".length"),
1581            "swift stream_complete must not use JS .length, got: {expr}"
1582        );
1583        assert!(
1584            !expr.contains("!= null"),
1585            "swift stream_complete must not use JS `!= null`, got: {expr}"
1586        );
1587    }
1588
1589    #[test]
1590    fn accessor_tool_calls_swift_uses_swift_flatmap() {
1591        let expr = StreamingFieldResolver::accessor("tool_calls", "swift", "chunks").unwrap();
1592        // Must use Swift closure syntax, not JS arrow
1593        assert!(
1594            !expr.contains("=>"),
1595            "swift tool_calls must not contain JS arrow `=>`, got: {expr}"
1596        );
1597        assert!(
1598            expr.contains("flatMap"),
1599            "swift tool_calls must use flatMap, got: {expr}"
1600        );
1601        assert!(
1602            expr.contains("choices()"),
1603            "swift tool_calls must use .choices() method call, got: {expr}"
1604        );
1605        assert!(
1606            expr.contains("delta()"),
1607            "swift tool_calls must use .delta() method call, got: {expr}"
1608        );
1609        assert!(
1610            expr.contains("tool_calls()"),
1611            "swift tool_calls must use .tool_calls() method call, got: {expr}"
1612        );
1613    }
1614
1615    #[test]
1616    fn accessor_tool_calls_deep_path_swift_uses_method_calls_with_optional_chain() {
1617        // `tool_calls[0].function.name` must resolve to Swift method calls with
1618        // optional chaining because swift-bridge opaque refs expose fields as
1619        // methods that return Optional.
1620        let expr = StreamingFieldResolver::accessor("tool_calls[0].function.name", "swift", "chunks").unwrap();
1621        assert!(
1622            expr.contains("function()"),
1623            "swift deep tool_calls must use .function() method call, got: {expr}"
1624        );
1625        assert!(
1626            expr.contains("name()"),
1627            "swift deep tool_calls must use .name() method call, got: {expr}"
1628        );
1629        assert!(
1630            expr.contains(".toString()"),
1631            "swift deep tool_calls must convert RustString via .toString(), got: {expr}"
1632        );
1633        assert!(
1634            !expr.contains("=>"),
1635            "swift deep tool_calls must not use JS arrow syntax, got: {expr}"
1636        );
1637    }
1638
1639    #[test]
1640    fn accessor_finish_reason_swift_uses_swift_syntax() {
1641        let expr = StreamingFieldResolver::accessor("finish_reason", "swift", "chunks").unwrap();
1642        // Must use Swift isEmpty / last! syntax, not JS .length / undefined
1643        assert!(
1644            expr.contains("isEmpty"),
1645            "swift finish_reason must use .isEmpty, got: {expr}"
1646        );
1647        assert!(
1648            expr.contains(".last!"),
1649            "swift finish_reason must use .last!, got: {expr}"
1650        );
1651        assert!(
1652            expr.contains("finish_reason()"),
1653            "swift finish_reason must use .finish_reason() method call, got: {expr}"
1654        );
1655        assert!(
1656            expr.contains(".toString()"),
1657            "swift finish_reason must convert RustString via .toString(), got: {expr}"
1658        );
1659        assert!(
1660            !expr.contains("undefined"),
1661            "swift finish_reason must not use JS `undefined`, got: {expr}"
1662        );
1663        assert!(
1664            !expr.contains(".length"),
1665            "swift finish_reason must not use JS .length, got: {expr}"
1666        );
1667    }
1668
1669    #[test]
1670    fn accessor_usage_swift_uses_swift_syntax() {
1671        let expr = StreamingFieldResolver::accessor("usage", "swift", "chunks").unwrap();
1672        // Must use Swift isEmpty / last! syntax, not JS .length / undefined
1673        assert!(expr.contains("isEmpty"), "swift usage must use .isEmpty, got: {expr}");
1674        assert!(expr.contains(".last!"), "swift usage must use .last!, got: {expr}");
1675        assert!(
1676            expr.contains("usage()"),
1677            "swift usage must use .usage() method call, got: {expr}"
1678        );
1679        assert!(
1680            !expr.contains("undefined"),
1681            "swift usage must not use JS `undefined`, got: {expr}"
1682        );
1683        assert!(
1684            !expr.contains(".length"),
1685            "swift usage must not use JS .length, got: {expr}"
1686        );
1687    }
1688
1689    // ---------------------------------------------------------------------------
1690    // Bug regression: kotlin_android streaming assertions use property access
1691    // ---------------------------------------------------------------------------
1692
1693    #[test]
1694    fn kotlin_android_collect_snippet_uses_flow_to_list() {
1695        let snip = StreamingFieldResolver::collect_snippet("kotlin_android", "result", "chunks").unwrap();
1696        // Flow.toList() — not Iterator.asSequence().toList()
1697        assert!(
1698            snip.contains("result.toList()"),
1699            "kotlin_android collect must use Flow.toList(), got: {snip}"
1700        );
1701        assert!(
1702            !snip.contains("asSequence()"),
1703            "kotlin_android collect must NOT use asSequence(), got: {snip}"
1704        );
1705    }
1706
1707    #[test]
1708    fn kotlin_android_stream_content_uses_property_access() {
1709        let expr = StreamingFieldResolver::accessor("stream_content", "kotlin_android", "chunks").unwrap();
1710        assert!(
1711            expr.contains(".choices"),
1712            "kotlin_android stream_content must use .choices property, got: {expr}"
1713        );
1714        assert!(
1715            !expr.contains(".choices()"),
1716            "kotlin_android stream_content must NOT use .choices() getter, got: {expr}"
1717        );
1718        assert!(
1719            expr.contains(".delta"),
1720            "kotlin_android stream_content must use .delta property, got: {expr}"
1721        );
1722        assert!(
1723            !expr.contains(".delta()"),
1724            "kotlin_android stream_content must NOT use .delta() getter, got: {expr}"
1725        );
1726        assert!(
1727            expr.contains(".content"),
1728            "kotlin_android stream_content must use .content property, got: {expr}"
1729        );
1730        assert!(
1731            !expr.contains(".content()"),
1732            "kotlin_android stream_content must NOT use .content() getter, got: {expr}"
1733        );
1734    }
1735
1736    #[test]
1737    fn kotlin_android_finish_reason_uses_name_lowercase_not_get_value() {
1738        let expr = StreamingFieldResolver::accessor("finish_reason", "kotlin_android", "chunks").unwrap();
1739        assert!(
1740            expr.contains(".finishReason"),
1741            "kotlin_android finish_reason must use .finishReason property, got: {expr}"
1742        );
1743        assert!(
1744            !expr.contains(".finishReason()"),
1745            "kotlin_android finish_reason must NOT use .finishReason() getter, got: {expr}"
1746        );
1747        assert!(
1748            expr.contains(".name"),
1749            "kotlin_android finish_reason must use .name for enum wire value, got: {expr}"
1750        );
1751        assert!(
1752            expr.contains(".lowercase()"),
1753            "kotlin_android finish_reason must use .lowercase(), got: {expr}"
1754        );
1755        assert!(
1756            !expr.contains(".getValue()"),
1757            "kotlin_android finish_reason must NOT use .getValue(), got: {expr}"
1758        );
1759    }
1760
1761    #[test]
1762    fn kotlin_android_usage_uses_property_access() {
1763        let expr = StreamingFieldResolver::accessor("usage", "kotlin_android", "chunks").unwrap();
1764        assert!(
1765            expr.contains(".usage"),
1766            "kotlin_android usage must use .usage property, got: {expr}"
1767        );
1768        assert!(
1769            !expr.contains(".usage()"),
1770            "kotlin_android usage must NOT use .usage() getter, got: {expr}"
1771        );
1772    }
1773
1774    #[test]
1775    fn kotlin_android_deep_tool_calls_uses_property_access() {
1776        let expr = StreamingFieldResolver::accessor("tool_calls[0].function.name", "kotlin_android", "chunks").unwrap();
1777        assert!(
1778            expr.contains(".function"),
1779            "kotlin_android deep tool_calls must use .function property, got: {expr}"
1780        );
1781        assert!(
1782            !expr.contains(".function()"),
1783            "kotlin_android deep tool_calls must NOT use .function() getter, got: {expr}"
1784        );
1785        assert!(
1786            expr.contains(".name"),
1787            "kotlin_android deep tool_calls must use .name property, got: {expr}"
1788        );
1789        assert!(
1790            !expr.contains(".name()"),
1791            "kotlin_android deep tool_calls must NOT use .name() getter, got: {expr}"
1792        );
1793    }
1794}