Skip to main content

noyalib/
path.rs

1//! Path tracking for YAML structure locations.
2//!
3//! This module provides the [`Path`] type for tracking the location within a
4//! YAML document structure. It's primarily used for providing meaningful error
5//! messages that indicate exactly where in the YAML structure an error
6//! occurred.
7//!
8//! # Examples
9//!
10//! ```rust
11//! use noyalib::Path;
12//!
13//! // Represent the path: root -> "dependencies" -> "serde" -> "version"
14//! let root = Path::Root;
15//! let deps = Path::Map {
16//!     parent: &root,
17//!     key: "dependencies",
18//! };
19//! let serde = Path::Map {
20//!     parent: &deps,
21//!     key: "serde",
22//! };
23//! let version = Path::Map {
24//!     parent: &serde,
25//!     key: "version",
26//! };
27//!
28//! assert_eq!(version.to_string(), "dependencies.serde.version");
29//! ```
30//!
31//! # Query paths
32//!
33//! The path strings read by [`Value::get_path`](crate::Value::get_path),
34//! [`Value::query`](crate::Value::query), the borrowed reads, and every
35//! path-taking `cst::Document` method share one grammar: `.` separates
36//! mapping keys, `[n]` indexes a sequence, `*` and `[*]` match every
37//! child, and `..` descends recursively. A key that itself contains one
38//! of those characters is written as a bracket-quoted segment, `["a.b"]`
39//! or `['a[0]']`, inside which `\` escapes the next character.
40//! [`quote_key`] spells one such segment, [`push_key`] appends a key to
41//! a path in whichever form reads back as that key, and [`join_keys`]
42//! builds a whole path from literal keys:
43//!
44//! ```rust
45//! use noyalib::path::{join_keys, quote_key};
46//!
47//! assert_eq!(quote_key("app.kubernetes.io/name"), r#"["app.kubernetes.io/name"]"#);
48//! assert_eq!(
49//!     join_keys(["labels", "app.kubernetes.io/name"]),
50//!     r#"labels["app.kubernetes.io/name"]"#,
51//! );
52//! assert_eq!(join_keys(["server", "port"]), "server.port");
53//! ```
54
55// SPDX-License-Identifier: MIT OR Apache-2.0
56// Copyright (c) 2026 Noyalib. All rights reserved.
57
58use crate::prelude::*;
59use core::{fmt, fmt::Display, str::FromStr};
60
61/// Represents a path to a location within a YAML document structure.
62///
63/// `Path` is a recursive data structure that tracks the navigation path from
64/// the root of a YAML document to a specific value. It's used to provide
65/// context in error messages, helping users understand exactly where in their
66/// YAML an issue occurred.
67///
68/// # Variants
69///
70/// - [`Root`](Path::Root): The root of the document
71/// - [`Seq`](Path::Seq): An index into a sequence (array)
72/// - [`Map`](Path::Map): A key in a mapping (object)
73/// - [`Alias`](Path::Alias): Following an alias reference
74/// - [`Unknown`](Path::Unknown): Unknown or unspecified location
75///
76/// # Examples
77///
78/// ```rust
79/// use noyalib::Path;
80///
81/// // Building a path: servers[0].host
82/// let root = Path::Root;
83/// let servers = Path::Map {
84///     parent: &root,
85///     key: "servers",
86/// };
87/// let first = Path::Seq {
88///     parent: &servers,
89///     index: 0,
90/// };
91/// let host = Path::Map {
92///     parent: &first,
93///     key: "host",
94/// };
95///
96/// assert_eq!(host.to_string(), "servers[0].host");
97/// ```
98#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
99pub enum Path<'a> {
100    /// The root of the document.
101    ///
102    /// This is the starting point for all paths. When displayed, it shows as
103    /// `.`.
104    #[default]
105    Root,
106
107    /// An index into a sequence (array).
108    ///
109    /// # Fields
110    ///
111    /// - `parent`: The path to the sequence containing this element
112    /// - `index`: The zero-based index into the sequence
113    Seq {
114        /// The parent path (the sequence itself).
115        parent: &'a Self,
116        /// The index within the sequence (zero-based).
117        index: usize,
118    },
119
120    /// A key in a mapping (object).
121    ///
122    /// # Fields
123    ///
124    /// - `parent`: The path to the mapping containing this key
125    /// - `key`: The key name
126    Map {
127        /// The parent path (the mapping itself).
128        parent: &'a Self,
129        /// The key within the mapping.
130        key: &'a str,
131    },
132
133    /// Following an alias reference.
134    ///
135    /// This variant is used when traversing through a YAML alias (`*anchor`)
136    /// to indicate the path continues through an alias.
137    Alias {
138        /// The parent path before the alias.
139        parent: &'a Self,
140    },
141
142    /// Unknown or unspecified location.
143    ///
144    /// This variant is used when the exact location cannot be determined
145    /// but we still want to track that we're somewhere nested.
146    Unknown {
147        /// The parent path.
148        parent: &'a Self,
149    },
150}
151
152impl<'a> Path<'a> {
153    /// Creates a new sequence index path.
154    ///
155    /// # Examples
156    ///
157    /// ```rust
158    /// use noyalib::Path;
159    ///
160    /// let root = Path::Root;
161    /// let items = Path::Map {
162    ///     parent: &root,
163    ///     key: "items",
164    /// };
165    /// let first = items.index(0);
166    ///
167    /// assert_eq!(first.to_string(), "items[0]");
168    /// ```
169    #[must_use]
170    pub fn index(&'a self, index: usize) -> Self {
171        Path::Seq {
172            parent: self,
173            index,
174        }
175    }
176
177    /// Creates a new map key path.
178    ///
179    /// # Examples
180    ///
181    /// ```rust
182    /// use noyalib::Path;
183    ///
184    /// let root = Path::Root;
185    /// let config = root.key("config");
186    /// let host = config.key("host");
187    ///
188    /// assert_eq!(host.to_string(), "config.host");
189    /// ```
190    #[must_use]
191    pub fn key(&'a self, key: &'a str) -> Self {
192        Path::Map { parent: self, key }
193    }
194
195    /// Creates a new alias path.
196    ///
197    /// # Examples
198    ///
199    /// ```rust
200    /// use noyalib::Path;
201    ///
202    /// let root = Path::Root;
203    /// let alias_path = root.alias();
204    /// ```
205    #[must_use]
206    pub fn alias(&'a self) -> Self {
207        Path::Alias { parent: self }
208    }
209
210    /// Creates a new unknown path.
211    #[must_use]
212    pub fn unknown(&'a self) -> Self {
213        Path::Unknown { parent: self }
214    }
215
216    /// Returns `true` if this is the root path.
217    ///
218    /// # Examples
219    ///
220    /// ```rust
221    /// use noyalib::Path;
222    ///
223    /// let root = Path::Root;
224    /// assert!(root.is_root());
225    ///
226    /// let child = root.key("test");
227    /// assert!(!child.is_root());
228    /// ```
229    #[must_use]
230    pub fn is_root(&self) -> bool {
231        matches!(self, Path::Root)
232    }
233
234    /// Returns the parent path, if any.
235    ///
236    /// Returns `None` if this is the root path.
237    ///
238    /// # Examples
239    ///
240    /// ```rust
241    /// use noyalib::Path;
242    ///
243    /// let root = Path::Root;
244    /// let child = root.key("test");
245    ///
246    /// assert!(root.parent().is_none());
247    /// assert!(child.parent().is_some());
248    /// ```
249    #[must_use]
250    pub fn parent(&self) -> Option<&Self> {
251        match self {
252            Path::Root => None,
253            Path::Seq { parent, .. }
254            | Path::Map { parent, .. }
255            | Path::Alias { parent }
256            | Path::Unknown { parent } => Some(parent),
257        }
258    }
259
260    /// Returns the depth of this path (number of segments from root).
261    ///
262    /// # Examples
263    ///
264    /// ```rust
265    /// use noyalib::Path;
266    ///
267    /// let root = Path::Root;
268    /// assert_eq!(root.depth(), 0);
269    ///
270    /// let child = root.key("a").key("b");
271    /// // Note: each key() creates a new path on the stack
272    /// ```
273    #[must_use]
274    pub fn depth(&self) -> usize {
275        match self {
276            Path::Root => 0,
277            Path::Seq { parent, .. }
278            | Path::Map { parent, .. }
279            | Path::Alias { parent }
280            | Path::Unknown { parent } => 1 + parent.depth(),
281        }
282    }
283}
284
285impl Display for Path<'_> {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        /// Helper struct to format the parent path with a trailing dot if
288        /// needed.
289        struct Parent<'a>(&'a Path<'a>);
290
291        impl Display for Parent<'_> {
292            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293                match self.0 {
294                    Path::Root => Ok(()),
295                    path => write!(f, "{path}."),
296                }
297            }
298        }
299
300        /// Helper struct to format the parent path without trailing dot for
301        /// sequences.
302        struct ParentNoDot<'a>(&'a Path<'a>);
303
304        impl Display for ParentNoDot<'_> {
305            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306                match self.0 {
307                    Path::Root => Ok(()),
308                    path => write!(f, "{path}"),
309                }
310            }
311        }
312
313        match self {
314            Path::Root => f.write_str("."),
315            Path::Seq { parent, index } => {
316                write!(f, "{}[{}]", ParentNoDot(parent), index)
317            }
318            Path::Map { parent, key } => {
319                write!(f, "{}{}", Parent(parent), key)
320            }
321            Path::Alias { parent } => {
322                write!(f, "{}", Parent(parent))
323            }
324            Path::Unknown { parent } => {
325                write!(f, "{}?", Parent(parent))
326            }
327        }
328    }
329}
330
331// ── Query path parsing ──────────────────────────────────────────────────
332// Shared path parsing for value.rs and borrowed.rs query methods.
333
334/// A parsed segment in a [`QueryPath`].
335#[derive(Debug, Clone, Eq, PartialEq, Hash)]
336pub enum QuerySegment {
337    /// A key in a mapping.
338    Key(String),
339    /// An index in a sequence.
340    Index(usize),
341    /// Wildcard: matches all keys or all indices.
342    Wildcard,
343    /// Recursive descent: matches at any depth.
344    RecursiveDescent,
345}
346
347/// A validated query path.
348///
349/// Parsing is atomic: malformed input returns [`PathError`] and never
350/// exposes a successfully parsed prefix. Use this type when a path comes
351/// from an untrusted configuration, command line, or network request and
352/// the caller must distinguish an invalid path from a path that is valid
353/// but does not match a value.
354#[derive(Debug, Clone, Eq, PartialEq, Hash)]
355pub struct QueryPath(Vec<QuerySegment>);
356
357impl QueryPath {
358    /// Parse and validate a query path.
359    ///
360    /// The empty string is the root path. Empty segments, incomplete
361    /// brackets, invalid indices, and trailing separators are rejected.
362    pub fn parse(input: &str) -> Result<Self, PathError> {
363        parse_query_path_checked(input).map(Self)
364    }
365
366    /// Return the validated path segments.
367    #[must_use]
368    pub fn segments(&self) -> &[QuerySegment] {
369        &self.0
370    }
371
372    /// Return whether this path addresses the root value.
373    #[must_use]
374    pub fn is_root(&self) -> bool {
375        self.0.is_empty()
376    }
377}
378
379impl FromStr for QueryPath {
380    type Err = PathError;
381
382    fn from_str(input: &str) -> Result<Self, Self::Err> {
383        Self::parse(input)
384    }
385}
386
387impl Display for QueryPath {
388    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
389        let mut rendered = String::new();
390        let mut after_descent = false;
391        for segment in &self.0 {
392            match segment {
393                QuerySegment::Key(key) if is_plain_key(key) => {
394                    if !rendered.is_empty() && !after_descent {
395                        rendered.push('.');
396                    }
397                    rendered.push_str(key);
398                }
399                QuerySegment::Key(key) => rendered.push_str(&quote_key(key)),
400                QuerySegment::Index(index) => {
401                    use core::fmt::Write as _;
402                    write!(rendered, "[{index}]")?;
403                }
404                QuerySegment::Wildcard => rendered.push_str("[*]"),
405                QuerySegment::RecursiveDescent => rendered.push_str(".."),
406            }
407            after_descent = matches!(segment, QuerySegment::RecursiveDescent);
408        }
409        f.write_str(&rendered)
410    }
411}
412
413/// The category of a query-path syntax error.
414#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
415#[non_exhaustive]
416pub enum PathErrorKind {
417    /// A dot would create an empty mapping-key segment.
418    EmptySegment,
419    /// A dot appeared immediately before a bracket segment.
420    SeparatorBeforeBracket,
421    /// A bracket segment has no closing bracket.
422    UnterminatedBracket,
423    /// A quoted mapping key has no closing quote.
424    UnterminatedQuotedKey,
425    /// A quoted mapping key ends with an incomplete escape.
426    DanglingEscape,
427    /// A quoted mapping key is not followed by `]`.
428    ExpectedClosingBracket,
429    /// Bracket content is neither an index nor `*`.
430    InvalidBracketSegment,
431    /// A numeric sequence index does not fit in [`usize`].
432    IndexOverflow,
433    /// A closing bracket appeared outside a bracket segment.
434    StrayClosingBracket,
435    /// The path ends with a dot separator.
436    DanglingSeparator,
437}
438
439/// An error returned when a query path is syntactically invalid.
440#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
441pub struct PathError {
442    kind: PathErrorKind,
443}
444
445impl PathError {
446    const fn new(kind: PathErrorKind) -> Self {
447        Self { kind }
448    }
449
450    /// Return the stable error category.
451    #[must_use]
452    pub const fn kind(self) -> PathErrorKind {
453        self.kind
454    }
455}
456
457impl Display for PathError {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        let message = match self.kind {
460            PathErrorKind::EmptySegment => "query path contains an empty segment",
461            PathErrorKind::SeparatorBeforeBracket => {
462                "query path has a separator before a bracket segment"
463            }
464            PathErrorKind::UnterminatedBracket => "query path has an unterminated bracket",
465            PathErrorKind::UnterminatedQuotedKey => "query path has an unterminated quoted key",
466            PathErrorKind::DanglingEscape => "query path has a dangling quoted-key escape",
467            PathErrorKind::ExpectedClosingBracket => {
468                "a quoted query-path key must be followed by a closing bracket"
469            }
470            PathErrorKind::InvalidBracketSegment => {
471                "query-path bracket content must be an index or wildcard"
472            }
473            PathErrorKind::IndexOverflow => "query-path sequence index overflows usize",
474            PathErrorKind::StrayClosingBracket => "query path contains a stray closing bracket",
475            PathErrorKind::DanglingSeparator => "query path ends with a separator",
476        };
477        f.write_str(message)
478    }
479}
480
481#[cfg(feature = "std")]
482impl std::error::Error for PathError {}
483
484/// Parse a query path string into segments.
485///
486/// Supports:
487/// - Dot notation: `"foo.bar.baz"`
488/// - Bracket notation: `"items[0]"`
489/// - Quoted keys: `"labels[\"app.kubernetes.io/name\"]"` — the text
490///   between the quotes is one mapping key, whatever it contains, so a
491///   key holding `.`, `[`, `]`, or `*` is addressable. Either quote
492///   style works (`['a[0]']`, `["*"]`); inside both, `\` escapes the
493///   next character, so `["say \"hi\""]` is the key `say "hi"`.
494///   [`quote_key`] spells such a segment.
495/// - Mixed: `"items[0].name"`, `"items[0][\"a.b\"].c"`
496/// - Wildcard: `"items[*]"` or `"items.*"`
497/// - Recursive descent: `"..name"` (find `name` at any depth)
498///
499/// Malformed paths never produce a partial prefix. Invalid bracket
500/// content, unterminated quoted keys, stray closing brackets, and
501/// dangling separators resolve to an impossible sentinel segment so a
502/// read returns no match and a mutator reports its existing path error
503/// without touching an ancestor node.
504pub(crate) fn parse_query_path(path: &str) -> Vec<QuerySegment> {
505    parse_query_path_checked(path).unwrap_or_else(|_| {
506        // NUL is forbidden in YAML scalar content, so no parsed mapping
507        // can contain this key. Keeping the public callers' existing
508        // `Option` / `Result` shapes avoids a breaking API change while
509        // guaranteeing malformed input cannot resolve to the root or a
510        // successfully parsed prefix.
511        vec![QuerySegment::Key("\0noyalib::invalid-query-path\0".into())]
512    })
513}
514
515fn parse_query_path_checked(path: &str) -> Result<Vec<QuerySegment>, PathError> {
516    let mut segments = Vec::new();
517    let mut current = String::new();
518    let mut chars = path.chars().peekable();
519    let mut separator_pending = false;
520
521    while let Some(c) = chars.next() {
522        match c {
523            '.' => {
524                let had_current = !current.is_empty();
525                if had_current {
526                    segments.push(QuerySegment::Key(core::mem::take(&mut current)));
527                }
528                if chars.peek() == Some(&'.') {
529                    if separator_pending {
530                        return Err(PathError::new(PathErrorKind::EmptySegment));
531                    }
532                    let _ = chars.next();
533                    segments.push(QuerySegment::RecursiveDescent);
534                    separator_pending = false;
535                } else if (!had_current && segments.is_empty()) || separator_pending {
536                    return Err(PathError::new(PathErrorKind::EmptySegment));
537                } else {
538                    separator_pending = true;
539                }
540            }
541            '[' => {
542                if separator_pending {
543                    return Err(PathError::new(PathErrorKind::SeparatorBeforeBracket));
544                }
545                if !current.is_empty() {
546                    segments.push(QuerySegment::Key(core::mem::take(&mut current)));
547                }
548                if let Some(&quote @ ('"' | '\'')) = chars.peek() {
549                    let _ = chars.next();
550                    let key = read_quoted_key(&mut chars, quote)?;
551                    if chars.next() != Some(']') {
552                        return Err(PathError::new(PathErrorKind::ExpectedClosingBracket));
553                    }
554                    segments.push(QuerySegment::Key(key));
555                    separator_pending = false;
556                    continue;
557                }
558                let mut index_str = String::new();
559                let mut closed = false;
560                while let Some(&c) = chars.peek() {
561                    if c == ']' {
562                        let _ = chars.next();
563                        closed = true;
564                        break;
565                    }
566                    index_str.push(c);
567                    let _ = chars.next();
568                }
569                if !closed {
570                    return Err(PathError::new(PathErrorKind::UnterminatedBracket));
571                }
572                if index_str == "*" {
573                    segments.push(QuerySegment::Wildcard);
574                } else if index_str.bytes().all(|byte| byte.is_ascii_digit())
575                    && !index_str.is_empty()
576                {
577                    let idx = index_str
578                        .parse::<usize>()
579                        .map_err(|_| PathError::new(PathErrorKind::IndexOverflow))?;
580                    segments.push(QuerySegment::Index(idx));
581                } else {
582                    return Err(PathError::new(PathErrorKind::InvalidBracketSegment));
583                }
584                separator_pending = false;
585            }
586            ']' => return Err(PathError::new(PathErrorKind::StrayClosingBracket)),
587            '*' => {
588                if !current.is_empty() {
589                    segments.push(QuerySegment::Key(core::mem::take(&mut current)));
590                }
591                segments.push(QuerySegment::Wildcard);
592                separator_pending = false;
593            }
594            _ => {
595                current.push(c);
596                separator_pending = false;
597            }
598        }
599    }
600
601    if separator_pending {
602        return Err(PathError::new(PathErrorKind::DanglingSeparator));
603    }
604    if !current.is_empty() {
605        segments.push(QuerySegment::Key(current));
606    }
607
608    Ok(segments)
609}
610
611/// Read the body of a quoted key segment up to its closing `quote`,
612/// consuming the quote. `\` escapes the next character.
613fn read_quoted_key(
614    chars: &mut core::iter::Peekable<core::str::Chars<'_>>,
615    quote: char,
616) -> Result<String, PathError> {
617    let mut key = String::new();
618    while let Some(c) = chars.next() {
619        match c {
620            '\\' => {
621                let escaped = chars
622                    .next()
623                    .ok_or_else(|| PathError::new(PathErrorKind::DanglingEscape))?;
624                key.push(escaped);
625            }
626            c if c == quote => return Ok(key),
627            c => key.push(c),
628        }
629    }
630    Err(PathError::new(PathErrorKind::UnterminatedQuotedKey))
631}
632
633/// Whether the query grammar reads `key` back as itself when it is
634/// written plain: it is not empty and holds none of `.`, `[`, `]`, `*`.
635fn is_plain_key(key: &str) -> bool {
636    !key.is_empty() && !key.contains(['.', '[', ']', '*'])
637}
638
639/// Spell `key` as one bracket-quoted path segment, `["key"]`, that
640/// [`Value::get_path`](crate::Value::get_path) and every path-taking
641/// `cst::Document` method read back as exactly that mapping key. `"`
642/// and `\` inside the key are escaped.
643///
644/// # Examples
645///
646/// ```rust
647/// use noyalib::path::quote_key;
648///
649/// assert_eq!(quote_key("app.kubernetes.io/name"), r#"["app.kubernetes.io/name"]"#);
650/// assert_eq!(quote_key("*"), r#"["*"]"#);
651/// assert_eq!(quote_key(r#"say "hi""#), r#"["say \"hi\""]"#);
652/// ```
653#[must_use]
654pub fn quote_key(key: &str) -> String {
655    let mut out = String::with_capacity(key.len() + 4);
656    out.push_str("[\"");
657    for c in key.chars() {
658        if matches!(c, '"' | '\\') {
659            out.push('\\');
660        }
661        out.push(c);
662    }
663    out.push_str("\"]");
664    out
665}
666
667/// Append the mapping key `key` to `path`: in dot notation when the
668/// grammar reads the plain spelling back as that key, otherwise as the
669/// segment [`quote_key`] spells. A quoted segment needs no separator.
670///
671/// # Examples
672///
673/// ```rust
674/// use noyalib::path::push_key;
675///
676/// let mut path = String::from("items[0]");
677/// push_key(&mut path, "labels");
678/// push_key(&mut path, "app.kubernetes.io/name");
679/// assert_eq!(path, r#"items[0].labels["app.kubernetes.io/name"]"#);
680/// ```
681pub fn push_key(path: &mut String, key: &str) {
682    if is_plain_key(key) {
683        if !path.is_empty() {
684            path.push('.');
685        }
686        path.push_str(key);
687    } else {
688        path.push_str(&quote_key(key));
689    }
690}
691
692/// Build a path from literal mapping keys, one [`push_key`] per key, so
693/// every key reads back as itself whatever it contains.
694///
695/// # Examples
696///
697/// ```rust
698/// use noyalib::path::join_keys;
699///
700/// assert_eq!(join_keys(["server", "port"]), "server.port");
701/// assert_eq!(join_keys(["a", "b.c", "*"]), r#"a["b.c"]["*"]"#);
702/// assert_eq!(join_keys(Vec::<&str>::new()), "");
703/// ```
704#[must_use]
705pub fn join_keys<K: AsRef<str>>(keys: impl IntoIterator<Item = K>) -> String {
706    let mut path = String::new();
707    for key in keys {
708        push_key(&mut path, key.as_ref());
709    }
710    path
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    #[test]
718    fn test_path_root_display() {
719        let root = Path::Root;
720        assert_eq!(root.to_string(), ".");
721    }
722
723    #[test]
724    fn test_path_map_display() {
725        let root = Path::Root;
726        let key1 = Path::Map {
727            parent: &root,
728            key: "config",
729        };
730        assert_eq!(key1.to_string(), "config");
731
732        let key2 = Path::Map {
733            parent: &key1,
734            key: "server",
735        };
736        assert_eq!(key2.to_string(), "config.server");
737
738        let key3 = Path::Map {
739            parent: &key2,
740            key: "host",
741        };
742        assert_eq!(key3.to_string(), "config.server.host");
743    }
744
745    #[test]
746    fn test_path_seq_display() {
747        let root = Path::Root;
748        let items = Path::Map {
749            parent: &root,
750            key: "items",
751        };
752        let first = Path::Seq {
753            parent: &items,
754            index: 0,
755        };
756        assert_eq!(first.to_string(), "items[0]");
757
758        let second = Path::Seq {
759            parent: &items,
760            index: 1,
761        };
762        assert_eq!(second.to_string(), "items[1]");
763    }
764
765    #[test]
766    fn test_path_mixed_display() {
767        let root = Path::Root;
768        let servers = Path::Map {
769            parent: &root,
770            key: "servers",
771        };
772        let first = Path::Seq {
773            parent: &servers,
774            index: 0,
775        };
776        let host = Path::Map {
777            parent: &first,
778            key: "host",
779        };
780        assert_eq!(host.to_string(), "servers[0].host");
781    }
782
783    #[test]
784    fn test_path_unknown_display() {
785        let root = Path::Root;
786        let unknown = Path::Unknown { parent: &root };
787        assert_eq!(unknown.to_string(), "?"); // Root doesn't add a dot
788
789        let key = Path::Map {
790            parent: &root,
791            key: "test",
792        };
793        let unknown2 = Path::Unknown { parent: &key };
794        assert_eq!(unknown2.to_string(), "test.?");
795    }
796
797    #[test]
798    fn test_path_builder_methods() {
799        let root = Path::Root;
800        let config = root.key("config");
801        let items = config.key("items");
802        let first = items.index(0);
803        let name = first.key("name");
804
805        assert_eq!(name.to_string(), "config.items[0].name");
806    }
807
808    #[test]
809    fn test_path_is_root() {
810        let root = Path::Root;
811        assert!(root.is_root());
812
813        let child = root.key("test");
814        assert!(!child.is_root());
815    }
816
817    #[test]
818    fn test_path_parent() {
819        let root = Path::Root;
820        assert!(root.parent().is_none());
821
822        let child = Path::Map {
823            parent: &root,
824            key: "test",
825        };
826        assert_eq!(child.parent(), Some(&root));
827    }
828
829    #[test]
830    fn test_path_depth() {
831        let root = Path::Root;
832        assert_eq!(root.depth(), 0);
833
834        let d1 = Path::Map {
835            parent: &root,
836            key: "a",
837        };
838        assert_eq!(d1.depth(), 1);
839
840        let d2 = Path::Map {
841            parent: &d1,
842            key: "b",
843        };
844        assert_eq!(d2.depth(), 2);
845
846        let d3 = Path::Seq {
847            parent: &d2,
848            index: 0,
849        };
850        assert_eq!(d3.depth(), 3);
851    }
852
853    #[test]
854    fn test_path_default() {
855        let path: Path = Path::default();
856        assert!(path.is_root());
857    }
858
859    #[test]
860    fn test_path_equality() {
861        let root1 = Path::Root;
862        let root2 = Path::Root;
863        assert_eq!(root1, root2);
864
865        let key1 = Path::Map {
866            parent: &root1,
867            key: "test",
868        };
869        let key2 = Path::Map {
870            parent: &root2,
871            key: "test",
872        };
873        assert_eq!(key1, key2);
874
875        let key3 = Path::Map {
876            parent: &root1,
877            key: "other",
878        };
879        assert_ne!(key1, key3);
880    }
881
882    #[test]
883    fn test_path_alias() {
884        let root = Path::Root;
885        let alias = root.alias();
886        assert!(matches!(alias, Path::Alias { .. }));
887    }
888
889    #[test]
890    fn test_path_complex_real_world() {
891        // Simulate a real-world path like: dependencies.serde.features[0]
892        let root = Path::Root;
893        let deps = root.key("dependencies");
894        let serde = deps.key("serde");
895        let features = serde.key("features");
896        let first_feature = features.index(0);
897
898        assert_eq!(first_feature.to_string(), "dependencies.serde.features[0]");
899    }
900
901    #[test]
902    fn test_path_deeply_nested() {
903        let root = Path::Root;
904        let a = root.key("a");
905        let b = a.key("b");
906        let c = b.key("c");
907        let d = c.key("d");
908        let e = d.key("e");
909
910        assert_eq!(e.to_string(), "a.b.c.d.e");
911        assert_eq!(e.depth(), 5);
912    }
913
914    #[test]
915    fn parse_query_path_handles_inline_star_after_key() {
916        // `field*` — the `*` glyph mid-path with a non-empty
917        // accumulated key. The parser must flush the key first,
918        // then push a Wildcard segment.
919        let segments = parse_query_path("field*");
920        assert_eq!(segments.len(), 2);
921        assert!(matches!(&segments[0], QuerySegment::Key(s) if s == "field"));
922        assert!(matches!(&segments[1], QuerySegment::Wildcard));
923    }
924
925    #[test]
926    fn parse_query_path_rejects_unparseable_bracket_content() {
927        let segments = parse_query_path("items[abc]");
928        assert_eq!(segments.len(), 1);
929        assert!(matches!(&segments[0], QuerySegment::Key(s) if s.contains("invalid-query-path")));
930        assert!(parse_query_path_checked("items[abc]").is_err());
931    }
932
933    #[test]
934    fn parse_query_path_handles_standalone_star_segment() {
935        // `*` on its own — recursive descent's "match anything at
936        // this level" form.
937        let segments = parse_query_path("*");
938        assert_eq!(segments.len(), 1);
939        assert!(matches!(&segments[0], QuerySegment::Wildcard));
940    }
941
942    #[test]
943    fn parse_query_path_handles_recursive_descent() {
944        // `..name` — descend recursively, then look up `name`.
945        let segments = parse_query_path("..name");
946        assert_eq!(segments.len(), 2);
947        assert!(matches!(&segments[0], QuerySegment::RecursiveDescent));
948        assert!(matches!(&segments[1], QuerySegment::Key(s) if s == "name"));
949    }
950
951    fn keys(segments: &[QuerySegment]) -> Vec<&str> {
952        segments
953            .iter()
954            .map(|s| match s {
955                QuerySegment::Key(k) => k.as_str(),
956                QuerySegment::Index(_) => "<index>",
957                QuerySegment::Wildcard => "<wildcard>",
958                QuerySegment::RecursiveDescent => "<descent>",
959            })
960            .collect()
961    }
962
963    #[test]
964    fn parse_query_path_reads_a_quoted_bracket_segment_as_one_key() {
965        let segments = parse_query_path(r#"labels["app.kubernetes.io/name"]"#);
966        assert_eq!(keys(&segments), ["labels", "app.kubernetes.io/name"]);
967    }
968
969    #[test]
970    fn parse_query_path_quoted_key_may_hold_any_grammar_character() {
971        for (path, key) in [
972            (r#"["*"]"#, "*"),
973            (r#"["a[0]"]"#, "a[0]"),
974            ("['a]b']", "a]b"),
975            (r#"[""]"#, ""),
976            (r#"["say \"hi\""]"#, "say \"hi\""),
977            (r#"["back\\slash"]"#, "back\\slash"),
978            (r"['it\'s']", "it's"),
979            (r#"['double "quotes"']"#, "double \"quotes\""),
980            (r#"["..x"]"#, "..x"),
981        ] {
982            let segments = parse_query_path(path);
983            assert_eq!(keys(&segments), [key], "{path}");
984        }
985    }
986
987    #[test]
988    fn parse_query_path_mixes_quoted_keys_with_the_other_segments() {
989        let segments = parse_query_path(r#"items[0]["a.b"].c[*]['d']"#);
990        assert_eq!(
991            keys(&segments),
992            ["items", "<index>", "a.b", "c", "<wildcard>", "d"]
993        );
994        assert!(matches!(segments[1], QuerySegment::Index(0)));
995    }
996
997    #[test]
998    fn parse_query_path_rejects_unterminated_quoted_key() {
999        assert!(parse_query_path_checked(r#"a["bc"#).is_err());
1000        assert!(parse_query_path_checked("a['bc").is_err());
1001    }
1002
1003    #[test]
1004    fn parse_query_path_rejects_other_partial_grammars() {
1005        for path in ["a.", ".a", "a[0", "a]", "a.[0]", r#"a["b"x]"#] {
1006            assert!(parse_query_path_checked(path).is_err(), "{path}");
1007        }
1008    }
1009
1010    #[test]
1011    fn quote_key_round_trips_every_key_through_the_parser() {
1012        for key in [
1013            "plain",
1014            "a.b",
1015            "*",
1016            "a[0]",
1017            "]",
1018            "[",
1019            "",
1020            "say \"hi\"",
1021            "back\\slash",
1022            "'",
1023            "\\\"",
1024            "app.kubernetes.io/name",
1025            "..",
1026            "[*]",
1027            "trailing\\",
1028        ] {
1029            let segments = parse_query_path(&quote_key(key));
1030            assert_eq!(keys(&segments), [key], "{key:?} -> {:?}", quote_key(key));
1031        }
1032    }
1033
1034    #[test]
1035    fn join_keys_quotes_only_the_keys_the_grammar_would_misread() {
1036        assert_eq!(join_keys(["server", "port"]), "server.port");
1037        assert_eq!(join_keys(["only"]), "only");
1038        assert_eq!(join_keys(Vec::<&str>::new()), "");
1039        let path = join_keys(["labels", "app.io/name", "tier", "", "x*", "y"]);
1040        assert_eq!(path, r#"labels["app.io/name"].tier[""]["x*"].y"#);
1041        assert_eq!(
1042            keys(&parse_query_path(&path)),
1043            ["labels", "app.io/name", "tier", "", "x*", "y"]
1044        );
1045    }
1046
1047    #[test]
1048    fn push_key_after_an_index_or_a_quoted_segment_needs_no_separator() {
1049        let mut path = String::from("items[0]");
1050        push_key(&mut path, "name");
1051        assert_eq!(path, "items[0].name");
1052        let mut path = quote_key("a.b");
1053        push_key(&mut path, "c");
1054        push_key(&mut path, "d]");
1055        assert_eq!(path, r#"["a.b"].c["d]"]"#);
1056        assert_eq!(keys(&parse_query_path(&path)), ["a.b", "c", "d]"]);
1057    }
1058
1059    #[test]
1060    fn query_path_reports_stable_error_categories() {
1061        for (input, kind) in [
1062            (".a", PathErrorKind::EmptySegment),
1063            ("a.[0]", PathErrorKind::SeparatorBeforeBracket),
1064            ("a[0", PathErrorKind::UnterminatedBracket),
1065            (r#"a["b"#, PathErrorKind::UnterminatedQuotedKey),
1066            (r#"a["b"x]"#, PathErrorKind::ExpectedClosingBracket),
1067            ("a[no]", PathErrorKind::InvalidBracketSegment),
1068            ("a]", PathErrorKind::StrayClosingBracket),
1069            ("a.", PathErrorKind::DanglingSeparator),
1070        ] {
1071            assert_eq!(QueryPath::parse(input).unwrap_err().kind(), kind, "{input}");
1072        }
1073
1074        let overflow = format!("items[{}]", "9".repeat(usize::BITS as usize));
1075        assert_eq!(
1076            QueryPath::parse(&overflow).unwrap_err().kind(),
1077            PathErrorKind::IndexOverflow
1078        );
1079    }
1080
1081    #[test]
1082    fn query_path_display_is_canonical_and_round_trips() {
1083        for input in [
1084            "",
1085            "server.port",
1086            "items[12].name",
1087            r#"labels["app.example/name"]"#,
1088            "items[*].name",
1089            "..name",
1090            "root..name",
1091        ] {
1092            let path = QueryPath::parse(input).unwrap();
1093            let rendered = path.to_string();
1094            assert_eq!(
1095                QueryPath::parse(&rendered).unwrap(),
1096                path,
1097                "{input} -> {rendered}"
1098            );
1099        }
1100    }
1101}