Skip to main content

xianyun_core/
dyn_access.rs

1//! Port of `internal/dyn`: path-based traversal, type conversion, and
2//! related helpers for `serde_json::Value`/`serde_yaml::Value` trees produced
3//! by JSON or YAML decoding (the Go original operates on `map[string]any` /
4//! `[]any`; here we operate on a `serde_json::Value` tree, which is the
5//! closest Rust equivalent to Go's dynamically-typed decode result).
6
7use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Debug, Error, PartialEq, Eq)]
11pub enum DynError {
12    #[error("invalid path: {0:?}")]
13    InvalidPath(String),
14    #[error("{0}: not found")]
15    NotFound(String),
16    #[error("{0}: not a map (got {1})")]
17    NotAMap(String, &'static str),
18    #[error("{0}: not a slice (got {1})")]
19    NotASlice(String, &'static str),
20    #[error("{0}: type mismatch")]
21    TypeMismatch(String),
22}
23
24/// One segment of a parsed Helm-style path: either a map key or a slice index.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum HelmStep {
27    MapKey(String),
28    Index(usize),
29}
30
31/// Parses a Helm-style dotted path into a slice of steps. A backslash escapes
32/// a literal dot; trailing `[N]` bracket notation on a segment produces
33/// slice-index steps.
34pub fn parse_helm_path(path: &str) -> Result<Vec<HelmStep>, DynError> {
35    let mut steps = Vec::new();
36
37    for segment in split_path(path) {
38        let (name, indices) = split_indices(&segment, path)?;
39
40        if name.is_empty() {
41            return Err(DynError::InvalidPath(path.to_string()));
42        }
43
44        steps.push(HelmStep::MapKey(name));
45
46        for idx in indices {
47            steps.push(HelmStep::Index(idx));
48        }
49    }
50
51    Ok(steps)
52}
53
54fn split_indices(segment: &str, path: &str) -> Result<(String, Vec<usize>), DynError> {
55    let Some(open) = segment.find('[') else {
56        return Ok((segment.to_string(), Vec::new()));
57    };
58
59    let name = segment[..open].to_string();
60    let mut indices = Vec::new();
61    let mut rest = &segment[open..];
62
63    while !rest.is_empty() {
64        if !rest.starts_with('[') {
65            return Err(DynError::InvalidPath(path.to_string()));
66        }
67
68        let Some(end) = rest.find(']') else {
69            return Err(DynError::InvalidPath(path.to_string()));
70        };
71
72        let index: usize = rest[1..end]
73            .parse()
74            .map_err(|_| DynError::InvalidPath(path.to_string()))?;
75
76        indices.push(index);
77        rest = &rest[end + 1..];
78    }
79
80    Ok((name, indices))
81}
82
83fn split_path(path: &str) -> Vec<String> {
84    let mut segments = Vec::new();
85    let mut current = String::new();
86    let mut escaped = false;
87
88    for c in path.chars() {
89        if escaped {
90            if c != '.' {
91                current.push('\\');
92            }
93            current.push(c);
94            escaped = false;
95        } else if c == '\\' {
96            escaped = true;
97        } else if c == '.' {
98            segments.push(std::mem::take(&mut current));
99        } else {
100            current.push(c);
101        }
102    }
103
104    if escaped {
105        current.push('\\');
106    }
107
108    segments.push(current);
109    segments
110}
111
112fn append_step(accumulated: &str, step: &HelmStep) -> String {
113    match step {
114        HelmStep::MapKey(key) => {
115            if accumulated.is_empty() {
116                key.clone()
117            } else {
118                format!("{accumulated}.{key}")
119            }
120        }
121        HelmStep::Index(i) => format!("{accumulated}[{i}]"),
122    }
123}
124
125fn value_type_name(v: &Value) -> &'static str {
126    match v {
127        Value::Null => "null",
128        Value::Bool(_) => "bool",
129        Value::Number(_) => "number",
130        Value::String(_) => "string",
131        Value::Array(_) => "array",
132        Value::Object(_) => "object",
133    }
134}
135
136fn step_into<'a>(
137    current: &'a Value,
138    step: &HelmStep,
139    accumulated: &str,
140) -> Result<(Option<&'a Value>, bool), DynError> {
141    match step {
142        HelmStep::MapKey(key) => {
143            let Value::Object(map) = current else {
144                return Err(DynError::NotAMap(
145                    accumulated.to_string(),
146                    value_type_name(current),
147                ));
148            };
149
150            match map.get(key) {
151                Some(v) => Ok((Some(v), true)),
152                None => Ok((None, false)),
153            }
154        }
155        HelmStep::Index(idx) => {
156            let Value::Array(list) = current else {
157                return Err(DynError::NotASlice(
158                    accumulated.to_string(),
159                    value_type_name(current),
160                ));
161            };
162
163            if *idx >= list.len() {
164                return Ok((None, false));
165            }
166
167            Ok((Some(&list[*idx]), true))
168        }
169    }
170}
171
172/// Walks `data` along a Helm-style path and returns the leaf value.
173///
174/// Unlike the Go generic `Access[T]`, type extraction is left to the caller
175/// via the returned `Value`; helper accessors narrowing to a specific Rust
176/// type will be added alongside the real implementation.
177pub fn access<'a>(data: &'a Value, path: &str) -> Result<(Option<&'a Value>, bool), DynError> {
178    let steps = parse_helm_path(path)?;
179
180    let mut current = data;
181    let mut accumulated = String::new();
182
183    for (i, step) in steps.iter().enumerate() {
184        let is_last = i == steps.len() - 1;
185        accumulated = append_step(&accumulated, step);
186
187        let (next, found) = step_into(current, step, &accumulated)?;
188
189        if !found {
190            if is_last {
191                return Ok((None, false));
192            }
193            return Err(DynError::NotFound(accumulated));
194        }
195
196        if is_last {
197            return Ok((next, true));
198        }
199
200        current = next.unwrap();
201    }
202
203    Err(DynError::InvalidPath(path.to_string()))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use serde_json::json;
210
211    // Port of TestAccess. Ported as a single table-driven test, matching the
212    // Go structure. Each case asserts found/value/error the same way the Go
213    // subtests did.
214    #[test]
215    fn access_cases() {
216        struct Case {
217            name: &'static str,
218            data: Value,
219            path: &'static str,
220            want_value: Option<&'static str>,
221            want_found: bool,
222            want_err: Option<&'static str>,
223        }
224
225        let cases = vec![
226            Case {
227                name: "simple key",
228                data: json!({"name": "dog"}),
229                path: "name",
230                want_value: Some("dog"),
231                want_found: true,
232                want_err: None,
233            },
234            Case {
235                name: "nested map",
236                data: json!({"a": {"b": "c"}}),
237                path: "a.b",
238                want_value: Some("c"),
239                want_found: true,
240                want_err: None,
241            },
242            Case {
243                name: "slice index",
244                data: json!({"items": ["x", "y"]}),
245                path: "items[1]",
246                want_value: Some("y"),
247                want_found: true,
248                want_err: None,
249            },
250            Case {
251                name: "nested map inside slice",
252                data: json!({"items": [{"name": "dog"}]}),
253                path: "items[0].name",
254                want_value: Some("dog"),
255                want_found: true,
256                want_err: None,
257            },
258            Case {
259                name: "final key absent returns not found",
260                data: json!({"a": "b"}),
261                path: "missing",
262                want_value: None,
263                want_found: false,
264                want_err: None,
265            },
266            Case {
267                name: "final index out of range returns not found",
268                data: json!({"items": ["x"]}),
269                path: "items[5]",
270                want_value: None,
271                want_found: false,
272                want_err: None,
273            },
274            Case {
275                name: "intermediate key absent returns error",
276                data: json!({"a": "b"}),
277                path: "missing.child",
278                want_value: None,
279                want_found: false,
280                want_err: Some("missing: not found"),
281            },
282            Case {
283                name: "intermediate not a map returns error",
284                data: json!({"a": "notamap"}),
285                path: "a.b",
286                want_value: None,
287                want_found: false,
288                want_err: Some("a.b: not a map"),
289            },
290            Case {
291                name: "intermediate not a slice returns error",
292                data: json!({"a": "notaslice"}),
293                path: "a[0]",
294                want_value: None,
295                want_found: false,
296                want_err: Some("a[0]: not a slice"),
297            },
298            Case {
299                name: "escaped dot in key",
300                data: json!({"cert-manager.io/issuer": "my-issuer"}),
301                path: r"cert-manager\.io/issuer",
302                want_value: Some("my-issuer"),
303                want_found: true,
304                want_err: None,
305            },
306            Case {
307                name: "invalid path returns error",
308                data: json!({}),
309                path: "foo[notanint]",
310                want_value: None,
311                want_found: false,
312                want_err: Some("invalid path"),
313            },
314            Case {
315                name: "consecutive indices on one segment",
316                data: json!({"items": [["a", "b"]]}),
317                path: "items[0][1]",
318                want_value: Some("b"),
319                want_found: true,
320                want_err: None,
321            },
322            Case {
323                name: "unclosed bracket returns error",
324                data: json!({"items": ["x"]}),
325                path: "items[0",
326                want_value: None,
327                want_found: false,
328                want_err: Some("invalid path"),
329            },
330            Case {
331                name: "index-only path with no map key returns error",
332                data: json!({}),
333                path: "[0]",
334                want_value: None,
335                want_found: false,
336                want_err: Some("invalid path"),
337            },
338        ];
339
340        for case in cases {
341            let result = access(&case.data, case.path);
342
343            if let Some(err_substr) = case.want_err {
344                let err = result.unwrap_err();
345                assert!(err.to_string().contains(err_substr), "{}: {err}", case.name);
346                continue;
347            }
348
349            let (value, found) = result.unwrap_or_else(|e| panic!("{}: {e}", case.name));
350            assert_eq!(found, case.want_found, "{}", case.name);
351
352            if case.want_found {
353                assert_eq!(
354                    value.and_then(Value::as_str),
355                    case.want_value,
356                    "{}",
357                    case.name
358                );
359            }
360        }
361    }
362
363    // Port of TestAccess_TypedVariants: int/bool/float64/slice/map results.
364    #[test]
365    fn access_typed_variants() {
366        let data = json!({
367            "count": 42,
368            "enabled": true,
369            "nested": {"score": 2.75},
370            "list": [1, 2, 3],
371        });
372
373        let (v, found) = access(&data, "count").unwrap();
374        assert!(found);
375        assert_eq!(v.and_then(Value::as_i64), Some(42));
376
377        let (v, found) = access(&data, "enabled").unwrap();
378        assert!(found);
379        assert_eq!(v.and_then(Value::as_bool), Some(true));
380
381        let (v, found) = access(&data, "nested.score").unwrap();
382        assert!(found);
383        assert!((v.and_then(Value::as_f64).unwrap() - 2.75).abs() < 1e-9);
384
385        let (v, found) = access(&data, "list").unwrap();
386        assert!(found);
387        assert_eq!(v, Some(&json!([1, 2, 3])));
388
389        let (v, found) = access(&data, "nested").unwrap();
390        assert!(found);
391        assert_eq!(v, Some(&json!({"score": 2.75})));
392    }
393}