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