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