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        // Array fields (Option<Vec<T>>) dereference to Option<&[T]>, so unwrap_or needs &[].
192        let is_array = self.is_array(resolved);
193        let binding = if has_map_access {
194            format!("let {local_var} = {accessor}.unwrap_or(\"\");")
195        } else if is_array {
196            format!("let {local_var} = {accessor}.as_deref().unwrap_or(&[]);")
197        } else {
198            format!("let {local_var} = {accessor}.as_deref().unwrap_or(\"\");")
199        };
200        Some((binding, local_var))
201    }
202}
203
204/// Parse a dotted field path into segments, handling map access `foo[key]`
205/// and the special `.length` pseudo-property for collection sizes.
206fn parse_path(path: &str) -> Vec<PathSegment> {
207    let mut segments = Vec::new();
208    for part in path.split('.') {
209        if part == "length" || part == "count" || part == "size" {
210            segments.push(PathSegment::Length);
211        } else if let Some(bracket_pos) = part.find('[') {
212            let field = part[..bracket_pos].to_string();
213            let key = part[bracket_pos + 1..].trim_end_matches(']').to_string();
214            if key.is_empty() {
215                // `field[]` means "first element" — treat as ArrayField
216                segments.push(PathSegment::ArrayField(field));
217            } else {
218                segments.push(PathSegment::MapAccess { field, key });
219            }
220        } else {
221            segments.push(PathSegment::Field(part.to_string()));
222        }
223    }
224    segments
225}
226
227/// Render an accessor expression for the given language.
228fn render_accessor(segments: &[PathSegment], language: &str, result_var: &str) -> String {
229    match language {
230        "rust" => render_rust(segments, result_var),
231        "python" => render_dot_access(segments, result_var, "python"),
232        "typescript" | "node" => render_typescript(segments, result_var),
233        "wasm" => render_wasm(segments, result_var),
234        "go" => render_go(segments, result_var),
235        "java" => render_java(segments, result_var),
236        "csharp" => render_pascal_dot(segments, result_var),
237        "ruby" => render_dot_access(segments, result_var, "ruby"),
238        "php" => render_php(segments, result_var),
239        "elixir" => render_dot_access(segments, result_var, "elixir"),
240        "r" => render_r(segments, result_var),
241        "c" => render_c(segments, result_var),
242        _ => render_dot_access(segments, result_var, language),
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Per-language renderers
248// ---------------------------------------------------------------------------
249
250/// Rust: `result.foo.bar.baz` or `result.foo.bar.get("key").map(|s| s.as_str())`
251fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
252    let mut out = result_var.to_string();
253    for seg in segments {
254        match seg {
255            PathSegment::Field(f) => {
256                out.push('.');
257                out.push_str(&f.to_snake_case());
258            }
259            PathSegment::ArrayField(f) => {
260                out.push('.');
261                out.push_str(&f.to_snake_case());
262                out.push_str("[0]");
263            }
264            PathSegment::MapAccess { field, key } => {
265                out.push('.');
266                out.push_str(&field.to_snake_case());
267                out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
268            }
269            PathSegment::Length => {
270                out.push_str(".len()");
271            }
272        }
273    }
274    out
275}
276
277/// Simple dot access (Python, Ruby, Elixir): `result.foo.bar.baz`
278fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
279    let mut out = result_var.to_string();
280    for seg in segments {
281        match seg {
282            PathSegment::Field(f) => {
283                out.push('.');
284                out.push_str(f);
285            }
286            PathSegment::ArrayField(f) => {
287                if language == "elixir" {
288                    let current = std::mem::take(&mut out);
289                    out = format!("Enum.at({current}.{f}, 0)");
290                } else {
291                    out.push('.');
292                    out.push_str(f);
293                    out.push_str("[0]");
294                }
295            }
296            PathSegment::MapAccess { field, key } => {
297                out.push('.');
298                out.push_str(field);
299                // Elixir maps use bracket access (map["key"]), not method calls.
300                if language == "elixir" {
301                    out.push_str(&format!("[\"{key}\"]"));
302                } else {
303                    out.push_str(&format!(".get(\"{key}\")"));
304                }
305            }
306            PathSegment::Length => match language {
307                "ruby" => out.push_str(".length"),
308                "elixir" => {
309                    let current = std::mem::take(&mut out);
310                    out = format!("length({current})");
311                }
312                // Python and default: len()
313                _ => {
314                    let current = std::mem::take(&mut out);
315                    out = format!("len({current})");
316                }
317            },
318        }
319    }
320    out
321}
322
323/// TypeScript/Node: `result.foo.bar.baz` or `result.foo.bar["key"]`
324/// NAPI-RS generates camelCase field names, so snake_case segments are converted.
325fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
326    let mut out = result_var.to_string();
327    for seg in segments {
328        match seg {
329            PathSegment::Field(f) => {
330                out.push('.');
331                out.push_str(&f.to_lower_camel_case());
332            }
333            PathSegment::ArrayField(f) => {
334                out.push('.');
335                out.push_str(&f.to_lower_camel_case());
336                out.push_str("[0]");
337            }
338            PathSegment::MapAccess { field, key } => {
339                out.push('.');
340                out.push_str(&field.to_lower_camel_case());
341                out.push_str(&format!("[\"{key}\"]"));
342            }
343            PathSegment::Length => {
344                out.push_str(".length");
345            }
346        }
347    }
348    out
349}
350
351/// WASM: `result.foo.bar.baz` or `result.foo.bar.get("key")`
352/// WASM bindings return Maps (from BTreeMap via serde_wasm_bindgen),
353/// which require `.get("key")` instead of bracket notation.
354/// Generates camelCase field names, so snake_case segments are converted.
355fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
356    let mut out = result_var.to_string();
357    for seg in segments {
358        match seg {
359            PathSegment::Field(f) => {
360                out.push('.');
361                out.push_str(&f.to_lower_camel_case());
362            }
363            PathSegment::ArrayField(f) => {
364                out.push('.');
365                out.push_str(&f.to_lower_camel_case());
366                out.push_str("[0]");
367            }
368            PathSegment::MapAccess { field, key } => {
369                out.push('.');
370                out.push_str(&field.to_lower_camel_case());
371                out.push_str(&format!(".get(\"{key}\")"));
372            }
373            PathSegment::Length => {
374                out.push_str(".length");
375            }
376        }
377    }
378    out
379}
380
381/// Go: `result.Foo.Bar.HTML` (PascalCase with Go initialism uppercasing) or `result.Foo.Bar["key"]`
382///
383/// Uses `alef_codegen::naming::to_go_name` so that fields like `html`, `url`, `user_id`
384/// are rendered as `HTML`, `URL`, `UserID` — matching the Go binding generator.
385fn render_go(segments: &[PathSegment], result_var: &str) -> String {
386    let mut out = result_var.to_string();
387    for seg in segments {
388        match seg {
389            PathSegment::Field(f) => {
390                out.push('.');
391                out.push_str(&to_go_name(f));
392            }
393            PathSegment::ArrayField(f) => {
394                out.push('.');
395                out.push_str(&to_go_name(f));
396                out.push_str("[0]");
397            }
398            PathSegment::MapAccess { field, key } => {
399                out.push('.');
400                out.push_str(&to_go_name(field));
401                out.push_str(&format!("[\"{key}\"]"));
402            }
403            PathSegment::Length => {
404                let current = std::mem::take(&mut out);
405                out = format!("len({current})");
406            }
407        }
408    }
409    out
410}
411
412/// Java: `result.foo().bar().baz()` or `result.foo().bar().get("key")`
413/// Field names are converted to lowerCamelCase (Java convention).
414fn render_java(segments: &[PathSegment], result_var: &str) -> String {
415    let mut out = result_var.to_string();
416    for seg in segments {
417        match seg {
418            PathSegment::Field(f) => {
419                out.push('.');
420                out.push_str(&f.to_lower_camel_case());
421                out.push_str("()");
422            }
423            PathSegment::ArrayField(f) => {
424                out.push('.');
425                out.push_str(&f.to_lower_camel_case());
426                out.push_str("().getFirst()");
427            }
428            PathSegment::MapAccess { field, key } => {
429                out.push('.');
430                out.push_str(&field.to_lower_camel_case());
431                out.push_str(&format!("().get(\"{key}\")"));
432            }
433            PathSegment::Length => {
434                out.push_str(".size()");
435            }
436        }
437    }
438    out
439}
440
441/// Java accessor with Optional unwrapping for intermediate fields.
442///
443/// When an intermediate field is in the `optional_fields` set, `.orElseThrow()`
444/// is appended after the accessor call to unwrap the `Optional<T>`.
445fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
446    let mut out = result_var.to_string();
447    let mut path_so_far = String::new();
448    for (i, seg) in segments.iter().enumerate() {
449        let is_leaf = i == segments.len() - 1;
450        match seg {
451            PathSegment::Field(f) => {
452                if !path_so_far.is_empty() {
453                    path_so_far.push('.');
454                }
455                path_so_far.push_str(f);
456                out.push('.');
457                out.push_str(&f.to_lower_camel_case());
458                out.push_str("()");
459                // Unwrap intermediate Optional fields so downstream accessors work.
460                if !is_leaf && optional_fields.contains(&path_so_far) {
461                    out.push_str(".orElseThrow()");
462                }
463            }
464            PathSegment::ArrayField(f) => {
465                if !path_so_far.is_empty() {
466                    path_so_far.push('.');
467                }
468                path_so_far.push_str(f);
469                out.push('.');
470                out.push_str(&f.to_lower_camel_case());
471                out.push_str("().getFirst()");
472            }
473            PathSegment::MapAccess { field, key } => {
474                if !path_so_far.is_empty() {
475                    path_so_far.push('.');
476                }
477                path_so_far.push_str(field);
478                out.push('.');
479                out.push_str(&field.to_lower_camel_case());
480                out.push_str(&format!("().get(\"{key}\")"));
481            }
482            PathSegment::Length => {
483                out.push_str(".size()");
484            }
485        }
486    }
487    out
488}
489
490/// Rust accessor with Option unwrapping for intermediate fields.
491///
492/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
493/// is appended after the field access to unwrap the `Option<T>`.
494fn render_rust_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
495    let mut out = result_var.to_string();
496    let mut path_so_far = String::new();
497    for (i, seg) in segments.iter().enumerate() {
498        let is_leaf = i == segments.len() - 1;
499        match seg {
500            PathSegment::Field(f) => {
501                if !path_so_far.is_empty() {
502                    path_so_far.push('.');
503                }
504                path_so_far.push_str(f);
505                out.push('.');
506                out.push_str(&f.to_snake_case());
507                // Unwrap intermediate Optional fields so downstream accessors work.
508                if !is_leaf && optional_fields.contains(&path_so_far) {
509                    out.push_str(".as_ref().unwrap()");
510                }
511            }
512            PathSegment::ArrayField(f) => {
513                if !path_so_far.is_empty() {
514                    path_so_far.push('.');
515                }
516                path_so_far.push_str(f);
517                out.push('.');
518                out.push_str(&f.to_snake_case());
519                out.push_str("[0]");
520            }
521            PathSegment::MapAccess { field, key } => {
522                if !path_so_far.is_empty() {
523                    path_so_far.push('.');
524                }
525                path_so_far.push_str(field);
526                out.push('.');
527                out.push_str(&field.to_snake_case());
528                out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
529            }
530            PathSegment::Length => {
531                out.push_str(".len()");
532            }
533        }
534    }
535    out
536}
537
538/// C#: `result.Foo.Bar.Baz` (PascalCase properties)
539fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
540    let mut out = result_var.to_string();
541    for seg in segments {
542        match seg {
543            PathSegment::Field(f) => {
544                out.push('.');
545                out.push_str(&f.to_pascal_case());
546            }
547            PathSegment::ArrayField(f) => {
548                out.push('.');
549                out.push_str(&f.to_pascal_case());
550                out.push_str("[0]");
551            }
552            PathSegment::MapAccess { field, key } => {
553                out.push('.');
554                out.push_str(&field.to_pascal_case());
555                out.push_str(&format!("[\"{key}\"]"));
556            }
557            PathSegment::Length => {
558                out.push_str(".Count");
559            }
560        }
561    }
562    out
563}
564
565/// C# accessor with nullable unwrapping for intermediate fields.
566///
567/// When an intermediate field is in the `optional_fields` set, `!` (null-forgiving)
568/// is appended after the field access to unwrap the nullable type.
569fn render_csharp_with_optionals(
570    segments: &[PathSegment],
571    result_var: &str,
572    optional_fields: &HashSet<String>,
573) -> String {
574    let mut out = result_var.to_string();
575    let mut path_so_far = String::new();
576    for (i, seg) in segments.iter().enumerate() {
577        let is_leaf = i == segments.len() - 1;
578        match seg {
579            PathSegment::Field(f) => {
580                if !path_so_far.is_empty() {
581                    path_so_far.push('.');
582                }
583                path_so_far.push_str(f);
584                out.push('.');
585                out.push_str(&f.to_pascal_case());
586                // Unwrap intermediate nullable fields so downstream accessors work.
587                if !is_leaf && optional_fields.contains(&path_so_far) {
588                    out.push('!');
589                }
590            }
591            PathSegment::ArrayField(f) => {
592                if !path_so_far.is_empty() {
593                    path_so_far.push('.');
594                }
595                path_so_far.push_str(f);
596                out.push('.');
597                out.push_str(&f.to_pascal_case());
598                out.push_str("[0]");
599            }
600            PathSegment::MapAccess { field, key } => {
601                if !path_so_far.is_empty() {
602                    path_so_far.push('.');
603                }
604                path_so_far.push_str(field);
605                out.push('.');
606                out.push_str(&field.to_pascal_case());
607                out.push_str(&format!("[\"{key}\"]"));
608            }
609            PathSegment::Length => {
610                out.push_str(".Count");
611            }
612        }
613    }
614    out
615}
616
617/// PHP: `$result->foo->bar->baz` or `$result->foo->bar["key"]`
618fn render_php(segments: &[PathSegment], result_var: &str) -> String {
619    let mut out = result_var.to_string();
620    for seg in segments {
621        match seg {
622            PathSegment::Field(f) => {
623                out.push_str("->");
624                out.push_str(f);
625            }
626            PathSegment::ArrayField(f) => {
627                out.push_str("->");
628                out.push_str(f);
629                out.push_str("[0]");
630            }
631            PathSegment::MapAccess { field, key } => {
632                out.push_str("->");
633                out.push_str(field);
634                out.push_str(&format!("[\"{key}\"]"));
635            }
636            PathSegment::Length => {
637                let current = std::mem::take(&mut out);
638                out = format!("count({current})");
639            }
640        }
641    }
642    out
643}
644
645/// R: `result$foo$bar$baz` or `result$foo$bar[["key"]]`
646fn render_r(segments: &[PathSegment], result_var: &str) -> String {
647    let mut out = result_var.to_string();
648    for seg in segments {
649        match seg {
650            PathSegment::Field(f) => {
651                out.push('$');
652                out.push_str(f);
653            }
654            PathSegment::ArrayField(f) => {
655                out.push('$');
656                out.push_str(f);
657                out.push_str("[[1]]");
658            }
659            PathSegment::MapAccess { field, key } => {
660                out.push('$');
661                out.push_str(field);
662                out.push_str(&format!("[[\"{key}\"]]"));
663            }
664            PathSegment::Length => {
665                let current = std::mem::take(&mut out);
666                out = format!("length({current})");
667            }
668        }
669    }
670    out
671}
672
673/// C FFI: `{prefix}_result_foo_bar_baz({result})` accessor function style.
674fn render_c(segments: &[PathSegment], result_var: &str) -> String {
675    let mut parts = Vec::new();
676    let mut trailing_length = false;
677    for seg in segments {
678        match seg {
679            PathSegment::Field(f) | PathSegment::ArrayField(f) => parts.push(f.to_snake_case()),
680            PathSegment::MapAccess { field, key } => {
681                parts.push(field.to_snake_case());
682                parts.push(key.clone());
683            }
684            PathSegment::Length => {
685                trailing_length = true;
686            }
687        }
688    }
689    let suffix = parts.join("_");
690    if trailing_length {
691        format!("result_{suffix}_count({result_var})")
692    } else {
693        format!("result_{suffix}({result_var})")
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    fn make_resolver() -> FieldResolver {
702        let mut fields = HashMap::new();
703        fields.insert("title".to_string(), "metadata.document.title".to_string());
704        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
705        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
706        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
707        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
708        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
709
710        let mut optional = HashSet::new();
711        optional.insert("metadata.document.title".to_string());
712
713        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new())
714    }
715
716    fn make_resolver_with_doc_optional() -> FieldResolver {
717        let mut fields = HashMap::new();
718        fields.insert("title".to_string(), "metadata.document.title".to_string());
719        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
720
721        let mut optional = HashSet::new();
722        optional.insert("document".to_string());
723        optional.insert("metadata.document.title".to_string());
724        optional.insert("metadata.document".to_string());
725
726        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new())
727    }
728
729    #[test]
730    fn test_resolve_alias() {
731        let r = make_resolver();
732        assert_eq!(r.resolve("title"), "metadata.document.title");
733    }
734
735    #[test]
736    fn test_resolve_passthrough() {
737        let r = make_resolver();
738        assert_eq!(r.resolve("content"), "content");
739    }
740
741    #[test]
742    fn test_is_optional() {
743        let r = make_resolver();
744        assert!(r.is_optional("metadata.document.title"));
745        assert!(!r.is_optional("content"));
746    }
747
748    #[test]
749    fn test_accessor_rust_struct() {
750        let r = make_resolver();
751        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
752    }
753
754    #[test]
755    fn test_accessor_rust_map() {
756        let r = make_resolver();
757        assert_eq!(
758            r.accessor("tags", "rust", "result"),
759            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
760        );
761    }
762
763    #[test]
764    fn test_accessor_python() {
765        let r = make_resolver();
766        assert_eq!(
767            r.accessor("title", "python", "result"),
768            "result.metadata.document.title"
769        );
770    }
771
772    #[test]
773    fn test_accessor_go() {
774        let r = make_resolver();
775        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
776    }
777
778    #[test]
779    fn test_accessor_go_initialism_fields() {
780        // Verifies that Go initialism uppercasing is applied consistently with the
781        // binding generator — `html` → `HTML`, `url` → `URL`, etc.
782        let mut fields = std::collections::HashMap::new();
783        fields.insert("content".to_string(), "html".to_string());
784        fields.insert("link_url".to_string(), "links.url".to_string());
785        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &HashSet::new());
786
787        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
788        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
789        // Direct field access without alias.
790        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
791        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
792        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
793        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
794        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
795        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
796    }
797
798    #[test]
799    fn test_accessor_typescript() {
800        let r = make_resolver();
801        assert_eq!(
802            r.accessor("title", "typescript", "result"),
803            "result.metadata.document.title"
804        );
805    }
806
807    #[test]
808    fn test_accessor_typescript_snake_to_camel() {
809        let r = make_resolver();
810        assert_eq!(
811            r.accessor("og", "typescript", "result"),
812            "result.metadata.document.openGraph"
813        );
814        assert_eq!(
815            r.accessor("twitter", "typescript", "result"),
816            "result.metadata.document.twitterCard"
817        );
818        assert_eq!(
819            r.accessor("canonical", "typescript", "result"),
820            "result.metadata.document.canonicalUrl"
821        );
822    }
823
824    #[test]
825    fn test_accessor_typescript_map_snake_to_camel() {
826        let r = make_resolver();
827        assert_eq!(
828            r.accessor("og_tag", "typescript", "result"),
829            "result.metadata.openGraphTags[\"og_title\"]"
830        );
831    }
832
833    #[test]
834    fn test_accessor_node_alias() {
835        let r = make_resolver();
836        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
837    }
838
839    #[test]
840    fn test_accessor_wasm_camel_case() {
841        let r = make_resolver();
842        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
843        assert_eq!(
844            r.accessor("twitter", "wasm", "result"),
845            "result.metadata.document.twitterCard"
846        );
847        assert_eq!(
848            r.accessor("canonical", "wasm", "result"),
849            "result.metadata.document.canonicalUrl"
850        );
851    }
852
853    #[test]
854    fn test_accessor_wasm_map_access() {
855        let r = make_resolver();
856        // WASM returns Maps, which need .get("key") instead of ["key"]
857        assert_eq!(
858            r.accessor("og_tag", "wasm", "result"),
859            "result.metadata.openGraphTags.get(\"og_title\")"
860        );
861    }
862
863    #[test]
864    fn test_accessor_java() {
865        let r = make_resolver();
866        assert_eq!(
867            r.accessor("title", "java", "result"),
868            "result.metadata().document().title()"
869        );
870    }
871
872    #[test]
873    fn test_accessor_csharp() {
874        let r = make_resolver();
875        assert_eq!(
876            r.accessor("title", "csharp", "result"),
877            "result.Metadata.Document.Title"
878        );
879    }
880
881    #[test]
882    fn test_accessor_php() {
883        let r = make_resolver();
884        assert_eq!(
885            r.accessor("title", "php", "$result"),
886            "$result->metadata->document->title"
887        );
888    }
889
890    #[test]
891    fn test_accessor_r() {
892        let r = make_resolver();
893        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
894    }
895
896    #[test]
897    fn test_accessor_c() {
898        let r = make_resolver();
899        assert_eq!(
900            r.accessor("title", "c", "result"),
901            "result_metadata_document_title(result)"
902        );
903    }
904
905    #[test]
906    fn test_rust_unwrap_binding() {
907        let r = make_resolver();
908        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
909        assert_eq!(var, "metadata_document_title");
910        assert!(binding.contains("as_deref().unwrap_or(\"\")"));
911    }
912
913    #[test]
914    fn test_rust_unwrap_binding_non_optional() {
915        let r = make_resolver();
916        assert!(r.rust_unwrap_binding("content", "result").is_none());
917    }
918
919    #[test]
920    fn test_direct_field_no_alias() {
921        let r = make_resolver();
922        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
923        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
924    }
925
926    #[test]
927    fn test_accessor_rust_with_optionals() {
928        let r = make_resolver_with_doc_optional();
929        // "metadata.document" is optional, so it should be unwrapped
930        assert_eq!(
931            r.accessor("title", "rust", "result"),
932            "result.metadata.document.as_ref().unwrap().title"
933        );
934    }
935
936    #[test]
937    fn test_accessor_csharp_with_optionals() {
938        let r = make_resolver_with_doc_optional();
939        // "metadata.document" is optional, so it should be unwrapped
940        assert_eq!(
941            r.accessor("title", "csharp", "result"),
942            "result.Metadata.Document!.Title"
943        );
944    }
945
946    #[test]
947    fn test_accessor_rust_non_optional_field() {
948        let r = make_resolver();
949        // "content" is not optional, so no unwrapping needed
950        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
951    }
952
953    #[test]
954    fn test_accessor_csharp_non_optional_field() {
955        let r = make_resolver();
956        // "content" is not optional, so no unwrapping needed
957        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
958    }
959}