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