1use 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum HelmStep {
27 MapKey(String),
28 Index(usize),
29}
30
31pub 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
172pub 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 #[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 #[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}