Skip to main content

alef_e2e/
field_access.rs

1//! Field path resolution for nested struct/map access in e2e assertions.
2//!
3//! The `FieldResolver` maps fixture field paths (e.g., "metadata.title") to
4//! actual API struct paths (e.g., "metadata.document.title") and generates
5//! language-specific accessor expressions.
6
7use alef_codegen::naming::to_go_name;
8use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase};
9use std::collections::{HashMap, HashSet};
10
11/// Resolves fixture field paths to language-specific accessor expressions.
12pub struct FieldResolver {
13    aliases: HashMap<String, String>,
14    optional_fields: HashSet<String>,
15    result_fields: HashSet<String>,
16    array_fields: HashSet<String>,
17}
18
19/// A parsed segment of a field path.
20#[derive(Debug, Clone)]
21enum PathSegment {
22    /// Struct field access: `foo`
23    Field(String),
24    /// Array field access with index: `foo[0]`
25    ArrayField(String),
26    /// Map/dict key access: `foo[key]`
27    MapAccess { field: String, key: String },
28    /// Length/count of the preceding collection: `.length`
29    Length,
30}
31
32impl FieldResolver {
33    /// Create a new resolver from the e2e config's `fields` aliases,
34    /// `fields_optional` set, `result_fields` set, and `fields_array` set.
35    pub fn new(
36        fields: &HashMap<String, String>,
37        optional: &HashSet<String>,
38        result_fields: &HashSet<String>,
39        array_fields: &HashSet<String>,
40    ) -> Self {
41        Self {
42            aliases: fields.clone(),
43            optional_fields: optional.clone(),
44            result_fields: result_fields.clone(),
45            array_fields: array_fields.clone(),
46        }
47    }
48
49    /// Resolve a fixture field path to the actual struct path.
50    /// Falls back to the field itself if no alias exists.
51    pub fn resolve<'a>(&'a self, fixture_field: &'a str) -> &'a str {
52        self.aliases
53            .get(fixture_field)
54            .map(String::as_str)
55            .unwrap_or(fixture_field)
56    }
57
58    /// Check if a resolved field path is optional.
59    pub fn is_optional(&self, field: &str) -> bool {
60        if self.optional_fields.contains(field) {
61            return true;
62        }
63        // Also check with/without bracket notation: `json_ld.name` ↔ `json_ld[].name`
64        // Strip `[]` from each segment and retry.
65        let normalized = field.replace("[].", ".");
66        if normalized != field && self.optional_fields.contains(normalized.as_str()) {
67            return true;
68        }
69        // Try adding `[]` after known array fields.
70        for af in &self.array_fields {
71            if let Some(rest) = field.strip_prefix(af.as_str()) {
72                if let Some(rest) = rest.strip_prefix('.') {
73                    let with_bracket = format!("{af}[].{rest}");
74                    if self.optional_fields.contains(with_bracket.as_str()) {
75                        return true;
76                    }
77                }
78            }
79        }
80        false
81    }
82
83    /// Check if a fixture field has an explicit alias mapping.
84    pub fn has_alias(&self, fixture_field: &str) -> bool {
85        self.aliases.contains_key(fixture_field)
86    }
87
88    /// Check whether a fixture field path is valid for the configured result type.
89    ///
90    /// When `result_fields` is non-empty, this returns `true` only if the
91    /// first segment of the *resolved* field path appears in that set.
92    /// When `result_fields` is empty (not configured), all fields are
93    /// considered valid (backwards-compatible).
94    pub fn is_valid_for_result(&self, fixture_field: &str) -> bool {
95        if self.result_fields.is_empty() {
96            return true;
97        }
98        let resolved = self.resolve(fixture_field);
99        let first_segment = resolved.split('.').next().unwrap_or(resolved);
100        // Strip any map-access bracket suffix (e.g., "foo[key]" -> "foo").
101        let first_segment = first_segment.split('[').next().unwrap_or(first_segment);
102        self.result_fields.contains(first_segment)
103    }
104
105    /// Check if a resolved field is an array/Vec type.
106    pub fn is_array(&self, field: &str) -> bool {
107        self.array_fields.contains(field)
108    }
109
110    /// Check if a resolved field path ends with a map access (e.g., `foo[key]`).
111    /// This is needed because Go map access returns a value type (not a pointer),
112    /// so nil checks and pointer dereferences don't apply.
113    pub fn has_map_access(&self, fixture_field: &str) -> bool {
114        let resolved = self.resolve(fixture_field);
115        let segments = parse_path(resolved);
116        segments.iter().any(|s| matches!(s, PathSegment::MapAccess { .. }))
117    }
118
119    /// Generate a language-specific accessor expression.
120    /// `result_var` is the variable holding the function return value.
121    pub fn accessor(&self, fixture_field: &str, language: &str, result_var: &str) -> String {
122        let resolved = self.resolve(fixture_field);
123        let segments = parse_path(resolved);
124
125        // When a segment is an array field and has child segments following it,
126        // replace Field with ArrayField so renderers emit `[0]` indexing.
127        let segments = self.inject_array_indexing(segments);
128
129        match language {
130            "java" => render_java_with_optionals(&segments, result_var, &self.optional_fields),
131            "rust" => render_rust_with_optionals(&segments, result_var, &self.optional_fields),
132            "csharp" => render_csharp_with_optionals(&segments, result_var, &self.optional_fields),
133            _ => render_accessor(&segments, language, result_var),
134        }
135    }
136
137    /// Replace `Field` segments with `ArrayField` when the field is in `fields_array`
138    /// and is followed by further child property segments (i.e., we're accessing a
139    /// property on an element, not the array itself).
140    ///
141    /// Does NOT convert when the next segment is `Length` — `links.length` should
142    /// produce `len(result.links)`, not `len(result.links[0])`.
143    fn inject_array_indexing(&self, segments: Vec<PathSegment>) -> Vec<PathSegment> {
144        if self.array_fields.is_empty() {
145            return segments;
146        }
147        let len = segments.len();
148        let mut result = Vec::with_capacity(len);
149        let mut path_so_far = String::new();
150        for i in 0..len {
151            let seg = &segments[i];
152            match seg {
153                PathSegment::Field(f) => {
154                    if !path_so_far.is_empty() {
155                        path_so_far.push('.');
156                    }
157                    path_so_far.push_str(f);
158                    // Convert to ArrayField only if:
159                    // 1. There are more segments after this one
160                    // 2. The field is in fields_array
161                    // 3. The next segment is NOT Length (we want array size, not element size)
162                    let next_is_length = i + 1 < len && matches!(segments[i + 1], PathSegment::Length);
163                    if i + 1 < len && self.array_fields.contains(&path_so_far) && !next_is_length {
164                        result.push(PathSegment::ArrayField(f.clone()));
165                    } else {
166                        result.push(seg.clone());
167                    }
168                }
169                _ => {
170                    result.push(seg.clone());
171                }
172            }
173        }
174        result
175    }
176
177    /// Generate a Rust variable binding that unwraps an Optional string field.
178    /// Returns `(binding_line, local_var_name)` or `None` if the field is not optional.
179    pub fn rust_unwrap_binding(&self, fixture_field: &str, result_var: &str) -> Option<(String, String)> {
180        let resolved = self.resolve(fixture_field);
181        if !self.is_optional(resolved) {
182            return None;
183        }
184        let segments = parse_path(resolved);
185        let segments = self.inject_array_indexing(segments);
186        let local_var = resolved.replace(['.', '['], "_").replace(']', "");
187        let accessor = render_accessor(&segments, "rust", result_var);
188        // Map access (.get("key").map(|s| s.as_str())) already returns Option<&str>,
189        // so skip .as_deref() to avoid borrowing from a temporary.
190        let has_map_access = segments.iter().any(|s| matches!(s, PathSegment::MapAccess { .. }));
191        let binding = if has_map_access {
192            format!("let {local_var} = {accessor}.unwrap_or(\"\");")
193        } else {
194            format!("let {local_var} = {accessor}.as_deref().unwrap_or(\"\");")
195        };
196        Some((binding, local_var))
197    }
198}
199
200/// Parse a dotted field path into segments, handling map access `foo[key]`
201/// and the special `.length` pseudo-property for collection sizes.
202fn parse_path(path: &str) -> Vec<PathSegment> {
203    let mut segments = Vec::new();
204    for part in path.split('.') {
205        if part == "length" || part == "count" || part == "size" {
206            segments.push(PathSegment::Length);
207        } else if let Some(bracket_pos) = part.find('[') {
208            let field = part[..bracket_pos].to_string();
209            let key = part[bracket_pos + 1..].trim_end_matches(']').to_string();
210            if key.is_empty() {
211                // `field[]` means "first element" — treat as ArrayField
212                segments.push(PathSegment::ArrayField(field));
213            } else {
214                segments.push(PathSegment::MapAccess { field, key });
215            }
216        } else {
217            segments.push(PathSegment::Field(part.to_string()));
218        }
219    }
220    segments
221}
222
223/// Render an accessor expression for the given language.
224fn render_accessor(segments: &[PathSegment], language: &str, result_var: &str) -> String {
225    match language {
226        "rust" => render_rust(segments, result_var),
227        "python" => render_dot_access(segments, result_var, "python"),
228        "typescript" | "node" => render_typescript(segments, result_var),
229        "wasm" => render_wasm(segments, result_var),
230        "go" => render_go(segments, result_var),
231        "java" => render_java(segments, result_var),
232        "csharp" => render_pascal_dot(segments, result_var),
233        "ruby" => render_dot_access(segments, result_var, "ruby"),
234        "php" => render_php(segments, result_var),
235        "elixir" => render_dot_access(segments, result_var, "elixir"),
236        "r" => render_r(segments, result_var),
237        "c" => render_c(segments, result_var),
238        _ => render_dot_access(segments, result_var, language),
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Per-language renderers
244// ---------------------------------------------------------------------------
245
246/// Rust: `result.foo.bar.baz` or `result.foo.bar.get("key").map(|s| s.as_str())`
247fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
248    let mut out = result_var.to_string();
249    for seg in segments {
250        match seg {
251            PathSegment::Field(f) => {
252                out.push('.');
253                out.push_str(&f.to_snake_case());
254            }
255            PathSegment::ArrayField(f) => {
256                out.push('.');
257                out.push_str(&f.to_snake_case());
258                out.push_str("[0]");
259            }
260            PathSegment::MapAccess { field, key } => {
261                out.push('.');
262                out.push_str(&field.to_snake_case());
263                out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
264            }
265            PathSegment::Length => {
266                out.push_str(".len()");
267            }
268        }
269    }
270    out
271}
272
273/// Simple dot access (Python, Ruby, Elixir): `result.foo.bar.baz`
274fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
275    let mut out = result_var.to_string();
276    for seg in segments {
277        match seg {
278            PathSegment::Field(f) => {
279                out.push('.');
280                out.push_str(f);
281            }
282            PathSegment::ArrayField(f) => {
283                if language == "elixir" {
284                    let current = std::mem::take(&mut out);
285                    out = format!("Enum.at({current}.{f}, 0)");
286                } else {
287                    out.push('.');
288                    out.push_str(f);
289                    out.push_str("[0]");
290                }
291            }
292            PathSegment::MapAccess { field, key } => {
293                out.push('.');
294                out.push_str(field);
295                // Elixir maps use bracket access (map["key"]), not method calls.
296                if language == "elixir" {
297                    out.push_str(&format!("[\"{key}\"]"));
298                } else {
299                    out.push_str(&format!(".get(\"{key}\")"));
300                }
301            }
302            PathSegment::Length => match language {
303                "ruby" => out.push_str(".length"),
304                "elixir" => {
305                    let current = std::mem::take(&mut out);
306                    out = format!("length({current})");
307                }
308                // Python and default: len()
309                _ => {
310                    let current = std::mem::take(&mut out);
311                    out = format!("len({current})");
312                }
313            },
314        }
315    }
316    out
317}
318
319/// TypeScript/Node: `result.foo.bar.baz` or `result.foo.bar["key"]`
320/// NAPI-RS generates camelCase field names, so snake_case segments are converted.
321fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
322    let mut out = result_var.to_string();
323    for seg in segments {
324        match seg {
325            PathSegment::Field(f) => {
326                out.push('.');
327                out.push_str(&f.to_lower_camel_case());
328            }
329            PathSegment::ArrayField(f) => {
330                out.push('.');
331                out.push_str(&f.to_lower_camel_case());
332                out.push_str("[0]");
333            }
334            PathSegment::MapAccess { field, key } => {
335                out.push('.');
336                out.push_str(&field.to_lower_camel_case());
337                out.push_str(&format!("[\"{key}\"]"));
338            }
339            PathSegment::Length => {
340                out.push_str(".length");
341            }
342        }
343    }
344    out
345}
346
347/// WASM: `result.foo.bar.baz` or `result.foo.bar.get("key")`
348/// WASM bindings return Maps (from BTreeMap via serde_wasm_bindgen),
349/// which require `.get("key")` instead of bracket notation.
350/// Generates camelCase field names, so snake_case segments are converted.
351fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
352    let mut out = result_var.to_string();
353    for seg in segments {
354        match seg {
355            PathSegment::Field(f) => {
356                out.push('.');
357                out.push_str(&f.to_lower_camel_case());
358            }
359            PathSegment::ArrayField(f) => {
360                out.push('.');
361                out.push_str(&f.to_lower_camel_case());
362                out.push_str("[0]");
363            }
364            PathSegment::MapAccess { field, key } => {
365                out.push('.');
366                out.push_str(&field.to_lower_camel_case());
367                out.push_str(&format!(".get(\"{key}\")"));
368            }
369            PathSegment::Length => {
370                out.push_str(".length");
371            }
372        }
373    }
374    out
375}
376
377/// Go: `result.Foo.Bar.HTML` (PascalCase with Go initialism uppercasing) or `result.Foo.Bar["key"]`
378///
379/// Uses `alef_codegen::naming::to_go_name` so that fields like `html`, `url`, `user_id`
380/// are rendered as `HTML`, `URL`, `UserID` — matching the Go binding generator.
381fn render_go(segments: &[PathSegment], result_var: &str) -> String {
382    let mut out = result_var.to_string();
383    for seg in segments {
384        match seg {
385            PathSegment::Field(f) => {
386                out.push('.');
387                out.push_str(&to_go_name(f));
388            }
389            PathSegment::ArrayField(f) => {
390                out.push('.');
391                out.push_str(&to_go_name(f));
392                out.push_str("[0]");
393            }
394            PathSegment::MapAccess { field, key } => {
395                out.push('.');
396                out.push_str(&to_go_name(field));
397                out.push_str(&format!("[\"{key}\"]"));
398            }
399            PathSegment::Length => {
400                let current = std::mem::take(&mut out);
401                out = format!("len({current})");
402            }
403        }
404    }
405    out
406}
407
408/// Java: `result.foo().bar().baz()` or `result.foo().bar().get("key")`
409/// Field names are converted to lowerCamelCase (Java convention).
410fn render_java(segments: &[PathSegment], result_var: &str) -> String {
411    let mut out = result_var.to_string();
412    for seg in segments {
413        match seg {
414            PathSegment::Field(f) => {
415                out.push('.');
416                out.push_str(&f.to_lower_camel_case());
417                out.push_str("()");
418            }
419            PathSegment::ArrayField(f) => {
420                out.push('.');
421                out.push_str(&f.to_lower_camel_case());
422                out.push_str("().getFirst()");
423            }
424            PathSegment::MapAccess { field, key } => {
425                out.push('.');
426                out.push_str(&field.to_lower_camel_case());
427                out.push_str(&format!("().get(\"{key}\")"));
428            }
429            PathSegment::Length => {
430                out.push_str(".size()");
431            }
432        }
433    }
434    out
435}
436
437/// Java accessor with Optional unwrapping for intermediate fields.
438///
439/// When an intermediate field is in the `optional_fields` set, `.orElseThrow()`
440/// is appended after the accessor call to unwrap the `Optional<T>`.
441fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
442    let mut out = result_var.to_string();
443    let mut path_so_far = String::new();
444    for (i, seg) in segments.iter().enumerate() {
445        let is_leaf = i == segments.len() - 1;
446        match seg {
447            PathSegment::Field(f) => {
448                if !path_so_far.is_empty() {
449                    path_so_far.push('.');
450                }
451                path_so_far.push_str(f);
452                out.push('.');
453                out.push_str(&f.to_lower_camel_case());
454                out.push_str("()");
455                // Unwrap intermediate Optional fields so downstream accessors work.
456                if !is_leaf && optional_fields.contains(&path_so_far) {
457                    out.push_str(".orElseThrow()");
458                }
459            }
460            PathSegment::ArrayField(f) => {
461                if !path_so_far.is_empty() {
462                    path_so_far.push('.');
463                }
464                path_so_far.push_str(f);
465                out.push('.');
466                out.push_str(&f.to_lower_camel_case());
467                out.push_str("().getFirst()");
468            }
469            PathSegment::MapAccess { field, key } => {
470                if !path_so_far.is_empty() {
471                    path_so_far.push('.');
472                }
473                path_so_far.push_str(field);
474                out.push('.');
475                out.push_str(&field.to_lower_camel_case());
476                out.push_str(&format!("().get(\"{key}\")"));
477            }
478            PathSegment::Length => {
479                out.push_str(".size()");
480            }
481        }
482    }
483    out
484}
485
486/// Rust accessor with Option unwrapping for intermediate fields.
487///
488/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
489/// is appended after the field access to unwrap the `Option<T>`.
490fn render_rust_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
491    let mut out = result_var.to_string();
492    let mut path_so_far = String::new();
493    for (i, seg) in segments.iter().enumerate() {
494        let is_leaf = i == segments.len() - 1;
495        match seg {
496            PathSegment::Field(f) => {
497                if !path_so_far.is_empty() {
498                    path_so_far.push('.');
499                }
500                path_so_far.push_str(f);
501                out.push('.');
502                out.push_str(&f.to_snake_case());
503                // Unwrap intermediate Optional fields so downstream accessors work.
504                if !is_leaf && optional_fields.contains(&path_so_far) {
505                    out.push_str(".as_ref().unwrap()");
506                }
507            }
508            PathSegment::ArrayField(f) => {
509                if !path_so_far.is_empty() {
510                    path_so_far.push('.');
511                }
512                path_so_far.push_str(f);
513                out.push('.');
514                out.push_str(&f.to_snake_case());
515                out.push_str("[0]");
516            }
517            PathSegment::MapAccess { field, key } => {
518                if !path_so_far.is_empty() {
519                    path_so_far.push('.');
520                }
521                path_so_far.push_str(field);
522                out.push('.');
523                out.push_str(&field.to_snake_case());
524                out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
525            }
526            PathSegment::Length => {
527                out.push_str(".len()");
528            }
529        }
530    }
531    out
532}
533
534/// C#: `result.Foo.Bar.Baz` (PascalCase properties)
535fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
536    let mut out = result_var.to_string();
537    for seg in segments {
538        match seg {
539            PathSegment::Field(f) => {
540                out.push('.');
541                out.push_str(&f.to_pascal_case());
542            }
543            PathSegment::ArrayField(f) => {
544                out.push('.');
545                out.push_str(&f.to_pascal_case());
546                out.push_str("[0]");
547            }
548            PathSegment::MapAccess { field, key } => {
549                out.push('.');
550                out.push_str(&field.to_pascal_case());
551                out.push_str(&format!("[\"{key}\"]"));
552            }
553            PathSegment::Length => {
554                out.push_str(".Count");
555            }
556        }
557    }
558    out
559}
560
561/// C# accessor with nullable unwrapping for intermediate fields.
562///
563/// When an intermediate field is in the `optional_fields` set, `!` (null-forgiving)
564/// is appended after the field access to unwrap the nullable type.
565fn render_csharp_with_optionals(
566    segments: &[PathSegment],
567    result_var: &str,
568    optional_fields: &HashSet<String>,
569) -> String {
570    let mut out = result_var.to_string();
571    let mut path_so_far = String::new();
572    for (i, seg) in segments.iter().enumerate() {
573        let is_leaf = i == segments.len() - 1;
574        match seg {
575            PathSegment::Field(f) => {
576                if !path_so_far.is_empty() {
577                    path_so_far.push('.');
578                }
579                path_so_far.push_str(f);
580                out.push('.');
581                out.push_str(&f.to_pascal_case());
582                // Unwrap intermediate nullable fields so downstream accessors work.
583                if !is_leaf && optional_fields.contains(&path_so_far) {
584                    out.push('!');
585                }
586            }
587            PathSegment::ArrayField(f) => {
588                if !path_so_far.is_empty() {
589                    path_so_far.push('.');
590                }
591                path_so_far.push_str(f);
592                out.push('.');
593                out.push_str(&f.to_pascal_case());
594                out.push_str("[0]");
595            }
596            PathSegment::MapAccess { field, key } => {
597                if !path_so_far.is_empty() {
598                    path_so_far.push('.');
599                }
600                path_so_far.push_str(field);
601                out.push('.');
602                out.push_str(&field.to_pascal_case());
603                out.push_str(&format!("[\"{key}\"]"));
604            }
605            PathSegment::Length => {
606                out.push_str(".Count");
607            }
608        }
609    }
610    out
611}
612
613/// PHP: `$result->foo->bar->baz` or `$result->foo->bar["key"]`
614fn render_php(segments: &[PathSegment], result_var: &str) -> String {
615    let mut out = result_var.to_string();
616    for seg in segments {
617        match seg {
618            PathSegment::Field(f) => {
619                out.push_str("->");
620                out.push_str(f);
621            }
622            PathSegment::ArrayField(f) => {
623                out.push_str("->");
624                out.push_str(f);
625                out.push_str("[0]");
626            }
627            PathSegment::MapAccess { field, key } => {
628                out.push_str("->");
629                out.push_str(field);
630                out.push_str(&format!("[\"{key}\"]"));
631            }
632            PathSegment::Length => {
633                let current = std::mem::take(&mut out);
634                out = format!("count({current})");
635            }
636        }
637    }
638    out
639}
640
641/// R: `result$foo$bar$baz` or `result$foo$bar[["key"]]`
642fn render_r(segments: &[PathSegment], result_var: &str) -> String {
643    let mut out = result_var.to_string();
644    for seg in segments {
645        match seg {
646            PathSegment::Field(f) => {
647                out.push('$');
648                out.push_str(f);
649            }
650            PathSegment::ArrayField(f) => {
651                out.push('$');
652                out.push_str(f);
653                out.push_str("[[1]]");
654            }
655            PathSegment::MapAccess { field, key } => {
656                out.push('$');
657                out.push_str(field);
658                out.push_str(&format!("[[\"{key}\"]]"));
659            }
660            PathSegment::Length => {
661                let current = std::mem::take(&mut out);
662                out = format!("length({current})");
663            }
664        }
665    }
666    out
667}
668
669/// C FFI: `{prefix}_result_foo_bar_baz({result})` accessor function style.
670fn render_c(segments: &[PathSegment], result_var: &str) -> String {
671    let mut parts = Vec::new();
672    let mut trailing_length = false;
673    for seg in segments {
674        match seg {
675            PathSegment::Field(f) | PathSegment::ArrayField(f) => parts.push(f.to_snake_case()),
676            PathSegment::MapAccess { field, key } => {
677                parts.push(field.to_snake_case());
678                parts.push(key.clone());
679            }
680            PathSegment::Length => {
681                trailing_length = true;
682            }
683        }
684    }
685    let suffix = parts.join("_");
686    if trailing_length {
687        format!("result_{suffix}_count({result_var})")
688    } else {
689        format!("result_{suffix}({result_var})")
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    fn make_resolver() -> FieldResolver {
698        let mut fields = HashMap::new();
699        fields.insert("title".to_string(), "metadata.document.title".to_string());
700        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
701        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
702        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
703        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
704        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
705
706        let mut optional = HashSet::new();
707        optional.insert("metadata.document.title".to_string());
708
709        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new())
710    }
711
712    fn make_resolver_with_doc_optional() -> FieldResolver {
713        let mut fields = HashMap::new();
714        fields.insert("title".to_string(), "metadata.document.title".to_string());
715        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
716
717        let mut optional = HashSet::new();
718        optional.insert("document".to_string());
719        optional.insert("metadata.document.title".to_string());
720        optional.insert("metadata.document".to_string());
721
722        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new())
723    }
724
725    #[test]
726    fn test_resolve_alias() {
727        let r = make_resolver();
728        assert_eq!(r.resolve("title"), "metadata.document.title");
729    }
730
731    #[test]
732    fn test_resolve_passthrough() {
733        let r = make_resolver();
734        assert_eq!(r.resolve("content"), "content");
735    }
736
737    #[test]
738    fn test_is_optional() {
739        let r = make_resolver();
740        assert!(r.is_optional("metadata.document.title"));
741        assert!(!r.is_optional("content"));
742    }
743
744    #[test]
745    fn test_accessor_rust_struct() {
746        let r = make_resolver();
747        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
748    }
749
750    #[test]
751    fn test_accessor_rust_map() {
752        let r = make_resolver();
753        assert_eq!(
754            r.accessor("tags", "rust", "result"),
755            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
756        );
757    }
758
759    #[test]
760    fn test_accessor_python() {
761        let r = make_resolver();
762        assert_eq!(
763            r.accessor("title", "python", "result"),
764            "result.metadata.document.title"
765        );
766    }
767
768    #[test]
769    fn test_accessor_go() {
770        let r = make_resolver();
771        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
772    }
773
774    #[test]
775    fn test_accessor_go_initialism_fields() {
776        // Verifies that Go initialism uppercasing is applied consistently with the
777        // binding generator — `html` → `HTML`, `url` → `URL`, etc.
778        let mut fields = std::collections::HashMap::new();
779        fields.insert("content".to_string(), "html".to_string());
780        fields.insert("link_url".to_string(), "links.url".to_string());
781        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &HashSet::new());
782
783        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
784        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
785        // Direct field access without alias.
786        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
787        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
788        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
789        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
790        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
791        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
792    }
793
794    #[test]
795    fn test_accessor_typescript() {
796        let r = make_resolver();
797        assert_eq!(
798            r.accessor("title", "typescript", "result"),
799            "result.metadata.document.title"
800        );
801    }
802
803    #[test]
804    fn test_accessor_typescript_snake_to_camel() {
805        let r = make_resolver();
806        assert_eq!(
807            r.accessor("og", "typescript", "result"),
808            "result.metadata.document.openGraph"
809        );
810        assert_eq!(
811            r.accessor("twitter", "typescript", "result"),
812            "result.metadata.document.twitterCard"
813        );
814        assert_eq!(
815            r.accessor("canonical", "typescript", "result"),
816            "result.metadata.document.canonicalUrl"
817        );
818    }
819
820    #[test]
821    fn test_accessor_typescript_map_snake_to_camel() {
822        let r = make_resolver();
823        assert_eq!(
824            r.accessor("og_tag", "typescript", "result"),
825            "result.metadata.openGraphTags[\"og_title\"]"
826        );
827    }
828
829    #[test]
830    fn test_accessor_node_alias() {
831        let r = make_resolver();
832        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
833    }
834
835    #[test]
836    fn test_accessor_wasm_camel_case() {
837        let r = make_resolver();
838        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
839        assert_eq!(
840            r.accessor("twitter", "wasm", "result"),
841            "result.metadata.document.twitterCard"
842        );
843        assert_eq!(
844            r.accessor("canonical", "wasm", "result"),
845            "result.metadata.document.canonicalUrl"
846        );
847    }
848
849    #[test]
850    fn test_accessor_wasm_map_access() {
851        let r = make_resolver();
852        // WASM returns Maps, which need .get("key") instead of ["key"]
853        assert_eq!(
854            r.accessor("og_tag", "wasm", "result"),
855            "result.metadata.openGraphTags.get(\"og_title\")"
856        );
857    }
858
859    #[test]
860    fn test_accessor_java() {
861        let r = make_resolver();
862        assert_eq!(
863            r.accessor("title", "java", "result"),
864            "result.metadata().document().title()"
865        );
866    }
867
868    #[test]
869    fn test_accessor_csharp() {
870        let r = make_resolver();
871        assert_eq!(
872            r.accessor("title", "csharp", "result"),
873            "result.Metadata.Document.Title"
874        );
875    }
876
877    #[test]
878    fn test_accessor_php() {
879        let r = make_resolver();
880        assert_eq!(
881            r.accessor("title", "php", "$result"),
882            "$result->metadata->document->title"
883        );
884    }
885
886    #[test]
887    fn test_accessor_r() {
888        let r = make_resolver();
889        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
890    }
891
892    #[test]
893    fn test_accessor_c() {
894        let r = make_resolver();
895        assert_eq!(
896            r.accessor("title", "c", "result"),
897            "result_metadata_document_title(result)"
898        );
899    }
900
901    #[test]
902    fn test_rust_unwrap_binding() {
903        let r = make_resolver();
904        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
905        assert_eq!(var, "metadata_document_title");
906        assert!(binding.contains("as_deref().unwrap_or(\"\")"));
907    }
908
909    #[test]
910    fn test_rust_unwrap_binding_non_optional() {
911        let r = make_resolver();
912        assert!(r.rust_unwrap_binding("content", "result").is_none());
913    }
914
915    #[test]
916    fn test_direct_field_no_alias() {
917        let r = make_resolver();
918        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
919        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
920    }
921
922    #[test]
923    fn test_accessor_rust_with_optionals() {
924        let r = make_resolver_with_doc_optional();
925        // "metadata.document" is optional, so it should be unwrapped
926        assert_eq!(
927            r.accessor("title", "rust", "result"),
928            "result.metadata.document.as_ref().unwrap().title"
929        );
930    }
931
932    #[test]
933    fn test_accessor_csharp_with_optionals() {
934        let r = make_resolver_with_doc_optional();
935        // "metadata.document" is optional, so it should be unwrapped
936        assert_eq!(
937            r.accessor("title", "csharp", "result"),
938            "result.Metadata.Document!.Title"
939        );
940    }
941
942    #[test]
943    fn test_accessor_rust_non_optional_field() {
944        let r = make_resolver();
945        // "content" is not optional, so no unwrapping needed
946        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
947    }
948
949    #[test]
950    fn test_accessor_csharp_non_optional_field() {
951        let r = make_resolver();
952        // "content" is not optional, so no unwrapping needed
953        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
954    }
955}