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