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