Skip to main content

osp_cli/dsl/parse/
path.rs

1use thiserror::Error;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct PathExpression {
5    pub absolute: bool,
6    pub segments: Vec<PathSegment>,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct PathSegment {
11    pub name: Option<String>,
12    pub selectors: Vec<Selector>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Selector {
17    Fanout,
18    Index(i64),
19    Slice {
20        start: Option<i64>,
21        stop: Option<i64>,
22        step: Option<i64>,
23    },
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Error)]
27pub enum PathParseError {
28    #[error("path expression cannot be empty")]
29    EmptyExpression,
30    #[error("path expression cannot be only '.'")]
31    DotOnlyExpression,
32    #[error("unmatched ']' in path expression")]
33    UnmatchedClosingBracket,
34    #[error("empty path segment")]
35    EmptySegment,
36    #[error("unclosed '[' in path expression")]
37    UnclosedBracket,
38    #[error("unexpected character in path segment")]
39    UnexpectedSegmentCharacter,
40    #[error("unclosed '[' in path segment")]
41    UnclosedSegmentBracket,
42    #[error("slice selector has too many components")]
43    SliceTooManyComponents,
44    #[error("invalid list index: {content}")]
45    InvalidListIndex { content: String },
46    #[error("invalid integer in slice selector: {value}")]
47    InvalidSliceInteger { value: String },
48}
49
50type Result<T> = std::result::Result<T, PathParseError>;
51
52pub fn parse_path(input: &str) -> Result<PathExpression> {
53    let trimmed = input.trim();
54    if trimmed.is_empty() {
55        return Err(PathParseError::EmptyExpression);
56    }
57
58    let absolute = trimmed.starts_with('.');
59    let body = if absolute { &trimmed[1..] } else { trimmed };
60    if body.is_empty() {
61        return Err(PathParseError::DotOnlyExpression);
62    }
63
64    let raw_segments = split_path_segments(body)?;
65    let mut segments = Vec::with_capacity(raw_segments.len());
66    for raw_segment in raw_segments {
67        segments.push(parse_segment(&raw_segment)?);
68    }
69
70    Ok(PathExpression { absolute, segments })
71}
72
73pub fn requires_materialization(path: &PathExpression) -> bool {
74    path.segments.iter().any(|segment| {
75        segment.selectors.iter().any(|selector| match selector {
76            Selector::Index(index) => *index < 0,
77            Selector::Fanout => false,
78            Selector::Slice { start, stop, step } => {
79                !(start.is_none() && stop.is_none() && step.is_none())
80            }
81        })
82    })
83}
84
85pub fn expression_to_flat_key(path: &PathExpression) -> Option<String> {
86    let mut output = String::new();
87
88    for (index, segment) in path.segments.iter().enumerate() {
89        if index > 0 {
90            output.push('.');
91        }
92
93        if let Some(name) = &segment.name {
94            output.push_str(name);
95        } else {
96            return None;
97        }
98
99        for selector in &segment.selectors {
100            match selector {
101                Selector::Index(value) if *value >= 0 => {
102                    output.push('[');
103                    output.push_str(&value.to_string());
104                    output.push(']');
105                }
106                Selector::Fanout | Selector::Slice { .. } | Selector::Index(_) => return None,
107            }
108        }
109    }
110
111    if output.is_empty() {
112        None
113    } else {
114        Some(output)
115    }
116}
117
118fn split_path_segments(path: &str) -> Result<Vec<String>> {
119    let mut depth = 0usize;
120    let mut current = String::new();
121    let mut segments = Vec::new();
122
123    for ch in path.chars() {
124        match ch {
125            '[' => {
126                depth = depth.saturating_add(1);
127                current.push(ch);
128            }
129            ']' => {
130                if depth == 0 {
131                    return Err(PathParseError::UnmatchedClosingBracket);
132                }
133                depth -= 1;
134                current.push(ch);
135            }
136            '.' if depth == 0 => {
137                if current.is_empty() {
138                    return Err(PathParseError::EmptySegment);
139                }
140                segments.push(current);
141                current = String::new();
142            }
143            _ => current.push(ch),
144        }
145    }
146
147    if depth != 0 {
148        return Err(PathParseError::UnclosedBracket);
149    }
150    if current.is_empty() {
151        return Err(PathParseError::EmptySegment);
152    }
153    segments.push(current);
154
155    Ok(segments)
156}
157
158fn parse_segment(raw_segment: &str) -> Result<PathSegment> {
159    let mut name = String::new();
160    let mut selectors = Vec::new();
161    let chars: Vec<char> = raw_segment.chars().collect();
162    let mut index = 0usize;
163
164    while index < chars.len() && chars[index] != '[' {
165        name.push(chars[index]);
166        index += 1;
167    }
168
169    let name = if name.is_empty() { None } else { Some(name) };
170
171    while index < chars.len() {
172        if chars[index] != '[' {
173            return Err(PathParseError::UnexpectedSegmentCharacter);
174        }
175        index += 1;
176
177        let mut content = String::new();
178        while index < chars.len() && chars[index] != ']' {
179            content.push(chars[index]);
180            index += 1;
181        }
182        if index == chars.len() {
183            return Err(PathParseError::UnclosedSegmentBracket);
184        }
185        index += 1;
186
187        selectors.push(parse_selector(content.trim())?);
188    }
189
190    Ok(PathSegment { name, selectors })
191}
192
193fn parse_selector(content: &str) -> Result<Selector> {
194    if content.is_empty() {
195        return Ok(Selector::Fanout);
196    }
197
198    if content.contains(':') {
199        let parts: Vec<&str> = content.split(':').collect();
200        if parts.len() > 3 {
201            return Err(PathParseError::SliceTooManyComponents);
202        }
203
204        let start = parse_optional_i64(parts.first().copied().unwrap_or_default())?;
205        let stop = parse_optional_i64(parts.get(1).copied().unwrap_or_default())?;
206        let step = parse_optional_i64(parts.get(2).copied().unwrap_or_default())?;
207
208        return Ok(Selector::Slice { start, stop, step });
209    }
210
211    let index = content
212        .parse::<i64>()
213        .map_err(|_| PathParseError::InvalidListIndex {
214            content: content.to_string(),
215        })?;
216    Ok(Selector::Index(index))
217}
218
219fn parse_optional_i64(value: &str) -> Result<Option<i64>> {
220    if value.trim().is_empty() {
221        return Ok(None);
222    }
223    value
224        .trim()
225        .parse::<i64>()
226        .map(Some)
227        .map_err(|_| PathParseError::InvalidSliceInteger {
228            value: value.to_string(),
229        })
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{
235        PathParseError, Selector, expression_to_flat_key, parse_path, requires_materialization,
236    };
237
238    #[test]
239    fn parses_dotted_path_with_selectors() {
240        let path = parse_path("members[0].uid").expect("path should parse");
241        assert_eq!(path.segments.len(), 2);
242        assert_eq!(path.segments[0].selectors, vec![Selector::Index(0)]);
243    }
244
245    #[test]
246    fn detects_materialization_for_negative_index() {
247        let path = parse_path("members[-1]").expect("path should parse");
248        assert!(requires_materialization(&path));
249    }
250
251    #[test]
252    fn full_slice_does_not_require_materialization() {
253        let path = parse_path("members[:]").expect("path should parse");
254        assert!(!requires_materialization(&path));
255    }
256
257    #[test]
258    fn non_full_slice_requires_materialization() {
259        let path = parse_path("members[1:]").expect("path should parse");
260        assert!(requires_materialization(&path));
261    }
262
263    #[test]
264    fn expression_to_flat_key_accepts_positive_index_only() {
265        let path = parse_path("members[0].uid").expect("path should parse");
266        assert_eq!(
267            expression_to_flat_key(&path),
268            Some("members[0].uid".to_string())
269        );
270
271        let path = parse_path("members[-1].uid").expect("path should parse");
272        assert_eq!(expression_to_flat_key(&path), None);
273    }
274
275    #[test]
276    fn parse_path_reports_typed_errors_for_common_invalid_inputs_unit() {
277        assert_eq!(
278            parse_path("   ").unwrap_err(),
279            PathParseError::EmptyExpression
280        );
281        assert_eq!(
282            parse_path(".").unwrap_err(),
283            PathParseError::DotOnlyExpression
284        );
285        assert_eq!(
286            parse_path("items.").unwrap_err(),
287            PathParseError::EmptySegment
288        );
289        assert_eq!(
290            parse_path("items[abc]").unwrap_err(),
291            PathParseError::InvalidListIndex {
292                content: "abc".to_string()
293            }
294        );
295        assert_eq!(
296            parse_path("items[:x]").unwrap_err(),
297            PathParseError::InvalidSliceInteger {
298                value: "x".to_string()
299            }
300        );
301    }
302}