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::{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}
15
16/// A parsed segment of a field path.
17#[derive(Debug, Clone)]
18enum PathSegment {
19    /// Struct field access: `foo`
20    Field(String),
21    /// Map/dict key access: `foo[key]`
22    MapAccess { field: String, key: String },
23}
24
25impl FieldResolver {
26    /// Create a new resolver from the e2e config's `fields` aliases and
27    /// `fields_optional` set.
28    pub fn new(fields: &HashMap<String, String>, optional: &HashSet<String>) -> Self {
29        Self {
30            aliases: fields.clone(),
31            optional_fields: optional.clone(),
32        }
33    }
34
35    /// Resolve a fixture field path to the actual struct path.
36    /// Falls back to the field itself if no alias exists.
37    pub fn resolve<'a>(&'a self, fixture_field: &'a str) -> &'a str {
38        self.aliases
39            .get(fixture_field)
40            .map(String::as_str)
41            .unwrap_or(fixture_field)
42    }
43
44    /// Check if a resolved field path is optional.
45    pub fn is_optional(&self, field: &str) -> bool {
46        self.optional_fields.contains(field)
47    }
48
49    /// Generate a language-specific accessor expression.
50    /// `result_var` is the variable holding the function return value.
51    pub fn accessor(&self, fixture_field: &str, language: &str, result_var: &str) -> String {
52        let resolved = self.resolve(fixture_field);
53        let segments = parse_path(resolved);
54        render_accessor(&segments, language, result_var)
55    }
56
57    /// Generate a Rust variable binding that unwraps an Optional string field.
58    /// Returns `(binding_line, local_var_name)` or `None` if the field is not optional.
59    pub fn rust_unwrap_binding(&self, fixture_field: &str, result_var: &str) -> Option<(String, String)> {
60        let resolved = self.resolve(fixture_field);
61        if !self.is_optional(resolved) {
62            return None;
63        }
64        let segments = parse_path(resolved);
65        let local_var = resolved.replace(['.', '['], "_").replace(']', "");
66        let accessor = render_accessor(&segments, "rust", result_var);
67        let binding = format!("let {local_var} = {accessor}.as_deref().unwrap_or(\"\");");
68        Some((binding, local_var))
69    }
70}
71
72/// Parse a dotted field path into segments, handling map access `foo[key]`.
73fn parse_path(path: &str) -> Vec<PathSegment> {
74    let mut segments = Vec::new();
75    for part in path.split('.') {
76        if let Some(bracket_pos) = part.find('[') {
77            let field = part[..bracket_pos].to_string();
78            let key = part[bracket_pos + 1..].trim_end_matches(']').to_string();
79            segments.push(PathSegment::MapAccess { field, key });
80        } else {
81            segments.push(PathSegment::Field(part.to_string()));
82        }
83    }
84    segments
85}
86
87/// Render an accessor expression for the given language.
88fn render_accessor(segments: &[PathSegment], language: &str, result_var: &str) -> String {
89    match language {
90        "rust" => render_rust(segments, result_var),
91        "python" => render_dot_access(segments, result_var, false),
92        "typescript" | "node" => render_typescript(segments, result_var),
93        "go" => render_go(segments, result_var),
94        "java" => render_java(segments, result_var),
95        "csharp" => render_pascal_dot(segments, result_var),
96        "ruby" => render_dot_access(segments, result_var, false),
97        "php" => render_php(segments, result_var),
98        "elixir" => render_dot_access(segments, result_var, false),
99        "r" => render_r(segments, result_var),
100        "c" => render_c(segments, result_var),
101        _ => render_dot_access(segments, result_var, false),
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Per-language renderers
107// ---------------------------------------------------------------------------
108
109/// Rust: `result.foo.bar.baz` or `result.foo.bar.get("key").map(|s| s.as_str())`
110fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
111    let mut out = result_var.to_string();
112    for seg in segments {
113        match seg {
114            PathSegment::Field(f) => {
115                out.push('.');
116                out.push_str(&f.to_snake_case());
117            }
118            PathSegment::MapAccess { field, key } => {
119                out.push('.');
120                out.push_str(&field.to_snake_case());
121                out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
122            }
123        }
124    }
125    out
126}
127
128/// Simple dot access (Python, Ruby, Elixir): `result.foo.bar.baz`
129fn render_dot_access(segments: &[PathSegment], result_var: &str, _pascal: bool) -> String {
130    let mut out = result_var.to_string();
131    for seg in segments {
132        match seg {
133            PathSegment::Field(f) => {
134                out.push('.');
135                out.push_str(f);
136            }
137            PathSegment::MapAccess { field, key } => {
138                out.push('.');
139                out.push_str(field);
140                out.push_str(&format!(".get(\"{key}\")"));
141            }
142        }
143    }
144    out
145}
146
147/// TypeScript/Node: `result.foo.bar.baz` or `result.foo.bar["key"]`
148fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
149    let mut out = result_var.to_string();
150    for seg in segments {
151        match seg {
152            PathSegment::Field(f) => {
153                out.push('.');
154                out.push_str(f);
155            }
156            PathSegment::MapAccess { field, key } => {
157                out.push('.');
158                out.push_str(field);
159                out.push_str(&format!("[\"{key}\"]"));
160            }
161        }
162    }
163    out
164}
165
166/// Go: `result.Foo.Bar.Baz` (PascalCase) or `result.Foo.Bar["key"]`
167fn render_go(segments: &[PathSegment], result_var: &str) -> String {
168    let mut out = result_var.to_string();
169    for seg in segments {
170        match seg {
171            PathSegment::Field(f) => {
172                out.push('.');
173                out.push_str(&f.to_pascal_case());
174            }
175            PathSegment::MapAccess { field, key } => {
176                out.push('.');
177                out.push_str(&field.to_pascal_case());
178                out.push_str(&format!("[\"{key}\"]"));
179            }
180        }
181    }
182    out
183}
184
185/// Java: `result.foo().bar().baz()` or `result.foo().bar().get("key")`
186fn render_java(segments: &[PathSegment], result_var: &str) -> String {
187    let mut out = result_var.to_string();
188    for seg in segments {
189        match seg {
190            PathSegment::Field(f) => {
191                out.push('.');
192                out.push_str(f);
193                out.push_str("()");
194            }
195            PathSegment::MapAccess { field, key } => {
196                out.push('.');
197                out.push_str(field);
198                out.push_str(&format!("().get(\"{key}\")"));
199            }
200        }
201    }
202    out
203}
204
205/// C#: `result.Foo.Bar.Baz` (PascalCase properties)
206fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
207    let mut out = result_var.to_string();
208    for seg in segments {
209        match seg {
210            PathSegment::Field(f) => {
211                out.push('.');
212                out.push_str(&f.to_pascal_case());
213            }
214            PathSegment::MapAccess { field, key } => {
215                out.push('.');
216                out.push_str(&field.to_pascal_case());
217                out.push_str(&format!("[\"{key}\"]"));
218            }
219        }
220    }
221    out
222}
223
224/// PHP: `$result->foo->bar->baz` or `$result->foo->bar["key"]`
225fn render_php(segments: &[PathSegment], result_var: &str) -> String {
226    let mut out = result_var.to_string();
227    for seg in segments {
228        match seg {
229            PathSegment::Field(f) => {
230                out.push_str("->");
231                out.push_str(f);
232            }
233            PathSegment::MapAccess { field, key } => {
234                out.push_str("->");
235                out.push_str(field);
236                out.push_str(&format!("[\"{key}\"]"));
237            }
238        }
239    }
240    out
241}
242
243/// R: `result$foo$bar$baz` or `result$foo$bar[["key"]]`
244fn render_r(segments: &[PathSegment], result_var: &str) -> String {
245    let mut out = result_var.to_string();
246    for seg in segments {
247        match seg {
248            PathSegment::Field(f) => {
249                out.push('$');
250                out.push_str(f);
251            }
252            PathSegment::MapAccess { field, key } => {
253                out.push('$');
254                out.push_str(field);
255                out.push_str(&format!("[[\"{key}\"]]"));
256            }
257        }
258    }
259    out
260}
261
262/// C FFI: `{prefix}_result_foo_bar_baz({result})` accessor function style.
263fn render_c(segments: &[PathSegment], result_var: &str) -> String {
264    let mut parts = Vec::new();
265    for seg in segments {
266        match seg {
267            PathSegment::Field(f) => parts.push(f.to_snake_case()),
268            PathSegment::MapAccess { field, key } => {
269                parts.push(field.to_snake_case());
270                parts.push(key.clone());
271            }
272        }
273    }
274    let suffix = parts.join("_");
275    format!("result_{suffix}({result_var})")
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn make_resolver() -> FieldResolver {
283        let mut fields = HashMap::new();
284        fields.insert("title".to_string(), "metadata.document.title".to_string());
285        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
286
287        let mut optional = HashSet::new();
288        optional.insert("metadata.document.title".to_string());
289
290        FieldResolver::new(&fields, &optional)
291    }
292
293    #[test]
294    fn test_resolve_alias() {
295        let r = make_resolver();
296        assert_eq!(r.resolve("title"), "metadata.document.title");
297    }
298
299    #[test]
300    fn test_resolve_passthrough() {
301        let r = make_resolver();
302        assert_eq!(r.resolve("content"), "content");
303    }
304
305    #[test]
306    fn test_is_optional() {
307        let r = make_resolver();
308        assert!(r.is_optional("metadata.document.title"));
309        assert!(!r.is_optional("content"));
310    }
311
312    #[test]
313    fn test_accessor_rust_struct() {
314        let r = make_resolver();
315        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
316    }
317
318    #[test]
319    fn test_accessor_rust_map() {
320        let r = make_resolver();
321        assert_eq!(
322            r.accessor("tags", "rust", "result"),
323            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
324        );
325    }
326
327    #[test]
328    fn test_accessor_python() {
329        let r = make_resolver();
330        assert_eq!(
331            r.accessor("title", "python", "result"),
332            "result.metadata.document.title"
333        );
334    }
335
336    #[test]
337    fn test_accessor_go() {
338        let r = make_resolver();
339        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
340    }
341
342    #[test]
343    fn test_accessor_typescript() {
344        let r = make_resolver();
345        assert_eq!(
346            r.accessor("title", "typescript", "result"),
347            "result.metadata.document.title"
348        );
349    }
350
351    #[test]
352    fn test_accessor_java() {
353        let r = make_resolver();
354        assert_eq!(
355            r.accessor("title", "java", "result"),
356            "result.metadata().document().title()"
357        );
358    }
359
360    #[test]
361    fn test_accessor_csharp() {
362        let r = make_resolver();
363        assert_eq!(
364            r.accessor("title", "csharp", "result"),
365            "result.Metadata.Document.Title"
366        );
367    }
368
369    #[test]
370    fn test_accessor_php() {
371        let r = make_resolver();
372        assert_eq!(
373            r.accessor("title", "php", "$result"),
374            "$result->metadata->document->title"
375        );
376    }
377
378    #[test]
379    fn test_accessor_r() {
380        let r = make_resolver();
381        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
382    }
383
384    #[test]
385    fn test_accessor_c() {
386        let r = make_resolver();
387        assert_eq!(
388            r.accessor("title", "c", "result"),
389            "result_metadata_document_title(result)"
390        );
391    }
392
393    #[test]
394    fn test_rust_unwrap_binding() {
395        let r = make_resolver();
396        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
397        assert_eq!(var, "metadata_document_title");
398        assert!(binding.contains("as_deref().unwrap_or(\"\")"));
399    }
400
401    #[test]
402    fn test_rust_unwrap_binding_non_optional() {
403        let r = make_resolver();
404        assert!(r.rust_unwrap_binding("content", "result").is_none());
405    }
406
407    #[test]
408    fn test_direct_field_no_alias() {
409        let r = make_resolver();
410        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
411        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
412    }
413}