Skip to main content

yaml_edit/
path.rs

1//! Path-based access to YAML documents.
2//!
3//! Provides convenient dot-separated path syntax for accessing nested YAML values
4//! like `"server.host"` or `"database.primary.port"`.
5//!
6//! Operations:
7//! [`try_get_path`](YamlPath::try_get_path),
8//! [`try_set_path`](YamlPath::try_set_path),
9//! [`try_remove_path`](YamlPath::try_remove_path).
10//! The `try_` variants return [`Result<_, PathError>`](PathError) so
11//! malformed paths, empty paths, missing keys, and container-type
12//! mismatches are visible rather than silently swallowed.
13//!
14//! The legacy `get_path` / `set_path` / `remove_path` methods still
15//! exist as thin wrappers that discard `PathError`; they are deprecated
16//! and will emit warnings.
17//!
18//! # Example
19//!
20//! ```
21//! use yaml_edit::{Document, path::YamlPath};
22//! use std::str::FromStr;
23//!
24//! let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
25//!
26//! // Get nested values
27//! let host = yaml.try_get_path("server.host").ok();
28//!
29//! // Set nested values (creates intermediate mappings)
30//! yaml.try_set_path("database.primary.host", "db.example.com").unwrap();
31//!
32//! // Remove nested values
33//! yaml.try_remove_path("server.port").unwrap();
34//! ```
35//!
36//! # Aliases
37//!
38//! When a path segment lands on an alias (`*name`), reads resolve it to
39//! the anchored value by default, matching
40//! [`get_resolved`](crate::anchor_resolution::DocumentResolvedExt::get_resolved)
41//! and [`merged`](crate::anchor_resolution::MappingMergedExt::merged):
42//!
43//! ```
44//! use yaml_edit::{Document, path::YamlPath};
45//! use std::str::FromStr;
46//!
47//! let doc = Document::from_str("shared: &shared\n  timeout: 30\nservice: *shared\n").unwrap();
48//! assert_eq!(doc.try_get_path("service.timeout").unwrap().to_string(), "30");
49//! ```
50//!
51//! Writes are different: writing through an alias changes the anchored
52//! node, and so changes every other alias pointing at it. That is
53//! refused by default. Pass an [`AliasPolicy`] to
54//! [`try_set_path_with`](YamlPath::try_set_path_with) to choose
55//! deliberately between updating the shared anchor
56//! ([`Follow`](AliasPolicy::Follow)) and replacing the alias with an
57//! independent copy ([`Expand`](AliasPolicy::Expand)).
58//!
59//! All operations preserve formatting, comments, and whitespace.
60
61use crate::yaml::Mapping;
62
63/// Trait for YAML types that support path-based access.
64///
65/// Path syntax uses dots (`.`) as separators to navigate nested mappings.
66/// For example, `"server.database.host"` accesses:
67/// ```yaml
68/// server:
69///   database:
70///     host: value
71/// ```
72///
73/// The `try_` methods ([`try_get_path`](Self::try_get_path),
74/// [`try_set_path`](Self::try_set_path),
75/// [`try_remove_path`](Self::try_remove_path)) return
76/// `Result<_, PathError>` and are the recommended API. The older
77/// [`get_path`](Self::get_path) / [`set_path`](Self::set_path) /
78/// [`remove_path`](Self::remove_path) methods are deprecated wrappers
79/// that discard the error and silently no-op.
80pub trait YamlPath {
81    /// Get a value at a nested path, returning a specific [`PathError`]
82    /// on failure instead of `None`.
83    ///
84    /// A tagged collection (`key: !custom` over a block mapping, say) is
85    /// descended into as the collection it wraps; the tag stays put.
86    ///
87    /// # Errors
88    ///
89    /// - [`PathError::Parse`] for a malformed path.
90    /// - [`PathError::EmptyPath`] when the path parses to zero segments.
91    /// - [`PathError::NoRoot`] when the receiver has no value to descend into.
92    /// - [`PathError::TypeMismatch`] when a segment tries to descend into a
93    ///   value of the wrong container type.
94    /// - [`PathError::NotFound`] when a segment addresses a key/index that
95    ///   does not exist.
96    fn try_get_path(&self, path: &str) -> Result<crate::as_yaml::YamlNode, PathError> {
97        self.try_get_path_with(path, AliasPolicy::Follow)
98    }
99
100    /// Like [`try_get_path`](Self::try_get_path), but with an explicit
101    /// [`AliasPolicy`] for segments that land on an alias.
102    ///
103    /// # Errors
104    ///
105    /// As [`try_get_path`](Self::try_get_path), plus
106    /// [`PathError::AliasRefused`] under [`AliasPolicy::Refuse`] and
107    /// [`PathError::UndefinedAlias`] when an alias names an anchor that
108    /// is not defined in the document.
109    fn try_get_path_with(
110        &self,
111        path: &str,
112        policy: AliasPolicy,
113    ) -> Result<crate::as_yaml::YamlNode, PathError>;
114
115    /// Set a value at a nested path, creating intermediate mappings /
116    /// sequences as needed. Returns a specific [`PathError`] on failure
117    /// instead of silently no-oping.
118    ///
119    /// # Errors
120    ///
121    /// - [`PathError::Parse`] for a malformed path.
122    /// - [`PathError::EmptyPath`] when the path parses to zero segments.
123    /// - [`PathError::NoRoot`] when the receiver has no root mapping to
124    ///   write into (Document with no root, or with a scalar/sequence root).
125    /// - [`PathError::TypeMismatch`] when an intermediate segment lands on
126    ///   a scalar that cannot be turned into a container.
127    fn try_set_path(&self, path: &str, value: impl crate::AsYaml) -> Result<(), PathError> {
128        self.try_set_path_with(path, value, AliasPolicy::Refuse)
129    }
130
131    /// Like [`try_set_path`](Self::try_set_path), but with an explicit
132    /// [`AliasPolicy`] for intermediate segments that land on an alias.
133    ///
134    /// Writing through an alias changes the anchored node, and therefore
135    /// every other alias that references it; that is why the default is
136    /// [`AliasPolicy::Refuse`] and this opt-in exists.
137    ///
138    /// # Errors
139    ///
140    /// As [`try_set_path`](Self::try_set_path), plus
141    /// [`PathError::AliasRefused`] under [`AliasPolicy::Refuse`] and
142    /// [`PathError::UndefinedAlias`] when an alias names an anchor that
143    /// is not defined in the document.
144    fn try_set_path_with(
145        &self,
146        path: &str,
147        value: impl crate::AsYaml,
148        policy: AliasPolicy,
149    ) -> Result<(), PathError>;
150
151    /// Remove a value at a nested path. Returns the removed
152    /// [`YamlNode`](crate::as_yaml::YamlNode) on success, or a specific
153    /// [`PathError`] describing why the removal did not happen.
154    ///
155    /// One entry is removed. Duplicate keys are legal YAML, so a path can
156    /// still resolve afterwards; use
157    /// [`Mapping::remove_nth_occurrence`](crate::Mapping::remove_nth_occurrence)
158    /// to choose between them.
159    ///
160    /// # Errors
161    ///
162    /// Same shape as [`try_get_path`](Self::try_get_path).
163    fn try_remove_path(&self, path: &str) -> Result<crate::as_yaml::YamlNode, PathError> {
164        self.try_remove_path_with(path, AliasPolicy::Refuse)
165    }
166
167    /// Like [`try_remove_path`](Self::try_remove_path), but with an
168    /// explicit [`AliasPolicy`] for intermediate segments that land on
169    /// an alias.
170    ///
171    /// Removal is a mutation, so it defaults to
172    /// [`AliasPolicy::Refuse`] for the same reason as
173    /// [`try_set_path`](Self::try_set_path).
174    ///
175    /// # Errors
176    ///
177    /// As [`try_remove_path`](Self::try_remove_path), plus
178    /// [`PathError::AliasRefused`] and [`PathError::UndefinedAlias`].
179    fn try_remove_path_with(
180        &self,
181        path: &str,
182        policy: AliasPolicy,
183    ) -> Result<crate::as_yaml::YamlNode, PathError>;
184
185    /// Get a value at a nested path.
186    ///
187    /// This is a lossy wrapper around
188    /// [`try_get_path`](Self::try_get_path): every error becomes `None`,
189    /// so callers cannot distinguish "path parsed but nothing found"
190    /// from "path was malformed" or "descended through the wrong
191    /// container type."
192    ///
193    /// # Examples
194    ///
195    /// ```
196    /// use yaml_edit::{Document, path::YamlPath};
197    /// use std::str::FromStr;
198    ///
199    /// let yaml = Document::from_str("server:\n  host: localhost\n").unwrap();
200    /// #[allow(deprecated)]
201    /// let host = yaml.get_path("server.host");
202    /// assert!(host.is_some());
203    /// ```
204    #[deprecated(
205        since = "0.4.0",
206        note = "use try_get_path; get_path swallows PathError as None"
207    )]
208    fn get_path(&self, path: &str) -> Option<crate::as_yaml::YamlNode> {
209        self.try_get_path(path).ok()
210    }
211
212    /// Set a value at a nested path.
213    ///
214    /// Lossy wrapper around [`try_set_path`](Self::try_set_path): every
215    /// error is silently ignored. Callers get no signal that the write
216    /// failed (bad path, no root, descending through a scalar, ...).
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use yaml_edit::{Document, path::YamlPath};
222    /// use std::str::FromStr;
223    ///
224    /// let yaml = Document::from_str("name: test\n").unwrap();
225    /// #[allow(deprecated)]
226    /// yaml.set_path("server.host", "localhost");
227    /// #[allow(deprecated)]
228    /// yaml.set_path("server.port", 8080);
229    /// ```
230    #[deprecated(
231        since = "0.4.0",
232        note = "use try_set_path; set_path silently ignores PathError"
233    )]
234    fn set_path(&self, path: &str, value: impl crate::AsYaml) {
235        let _ = self.try_set_path(path, value);
236    }
237
238    /// Remove a value at a nested path. Returns `true` if a value was
239    /// removed.
240    ///
241    /// Lossy wrapper around [`try_remove_path`](Self::try_remove_path):
242    /// every error is reported as `false`, indistinguishable from
243    /// "path was well-formed but key not present."
244    ///
245    /// # Examples
246    ///
247    /// ```
248    /// use yaml_edit::{Document, path::YamlPath};
249    /// use std::str::FromStr;
250    ///
251    /// let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
252    /// #[allow(deprecated)]
253    /// {
254    ///     assert_eq!(yaml.remove_path("server.port"), true);
255    ///     assert_eq!(yaml.remove_path("server.missing"), false);
256    /// }
257    /// ```
258    #[deprecated(
259        since = "0.4.0",
260        note = "use try_remove_path; remove_path swallows PathError as false"
261    )]
262    fn remove_path(&self, path: &str) -> bool {
263        self.try_remove_path(path).is_ok()
264    }
265}
266
267/// Represents a segment in a YAML path.
268#[derive(Debug, Clone, PartialEq)]
269pub enum PathSegment {
270    /// A mapping key (e.g., "server" in "server.host")
271    Key(String),
272    /// An array index (e.g., `0` in "items\[0\]" or "items.0")
273    Index(usize),
274}
275
276/// Error from [`try_parse_path`].
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum PathParseError {
279    /// A `[` was not closed by `]`.
280    UnclosedIndex,
281    /// The text between `[` and `]` is not a `usize` index.
282    InvalidIndex(String),
283}
284
285impl std::fmt::Display for PathParseError {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        match self {
288            PathParseError::UnclosedIndex => {
289                write!(f, "unclosed '[' in path")
290            }
291            PathParseError::InvalidIndex(text) => {
292                write!(f, "invalid path index [{text}]")
293            }
294        }
295    }
296}
297
298impl std::error::Error for PathParseError {}
299
300/// The most null placeholders [`try_set_path`](YamlPath::try_set_path) will
301/// insert to reach an out-of-range sequence index.
302///
303/// Writing to `s[5]` on an empty sequence is a reasonable way to build one;
304/// writing to `s[900000]` is almost always a mistake or hostile input, and
305/// filling the gap would cost time and memory that grows faster than the
306/// index (each insert re-splices the sequence's children).
307pub const MAX_INDEX_GROWTH: usize = 256;
308
309/// Error returned by [`try_get_path`](YamlPath::try_get_path) /
310/// [`try_set_path`](YamlPath::try_set_path) /
311/// [`try_remove_path`](YamlPath::try_remove_path).
312///
313/// Distinguishes the several ways a path operation can fail so callers
314/// can react appropriately, instead of getting the previous silent
315/// no-op or ambiguous `None`.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum PathError {
318    /// The path string is malformed (unclosed `[`, non-`usize` index).
319    Parse(PathParseError),
320    /// The path parsed to zero segments (typically the empty string or
321    /// a run of stray dots).
322    EmptyPath,
323    /// The receiver has no root value to descend into: `Document` was
324    /// empty, or the requested operation needed a root mapping and the
325    /// document's root is a scalar / sequence.
326    NoRoot,
327    /// A segment tried to descend into a value of the wrong container
328    /// type -- e.g. treating a scalar as a mapping in `foo.bar` when
329    /// `foo` is a scalar. `at` is the segment index (`"foo"` in the
330    /// example above) that led to the type mismatch.
331    TypeMismatch {
332        /// The segment whose value was not the expected container type.
333        at: String,
334    },
335    /// The target segment does not exist. Applies to
336    /// [`try_get_path`](YamlPath::try_get_path) and
337    /// [`try_remove_path`](YamlPath::try_remove_path); `try_set_path`
338    /// creates missing intermediates and never reports this. `at` is
339    /// the missing segment.
340    NotFound {
341        /// The segment that could not be resolved.
342        at: String,
343    },
344    /// A segment landed on an alias (`*name`) and the active
345    /// [`AliasPolicy`] refused to go through it. `at` is the segment,
346    /// `alias` the anchor name it referenced.
347    AliasRefused {
348        /// The segment whose value is an alias.
349        at: String,
350        /// The anchor name the alias references, without the `*`.
351        alias: String,
352    },
353    /// Setting the requested index would have grown a sequence by more
354    /// than [`MAX_INDEX_GROWTH`] null placeholders.
355    ///
356    /// `try_set_path("s[900000]", v)` on a short sequence would otherwise
357    /// pad it with that many entries, so a path from untrusted input could
358    /// spend unbounded time and memory.
359    IndexTooFar {
360        /// The segment whose index was out of reach.
361        at: String,
362        /// The current length of the sequence.
363        len: usize,
364        /// The index that was asked for.
365        index: usize,
366    },
367    /// A segment landed on an alias whose anchor is not defined anywhere
368    /// in the document, so it could not be resolved.
369    UndefinedAlias {
370        /// The segment whose value is a dangling alias.
371        at: String,
372        /// The anchor name the alias references, without the `*`.
373        alias: String,
374    },
375}
376
377/// What path traversal should do when a segment lands on an alias
378/// (`*name`) that it needs to descend through.
379///
380/// Reads and writes want different answers. Reading through an alias is
381/// just a lookup, so [`try_get_path`](YamlPath::try_get_path) resolves by
382/// default. Writing through one mutates the shared anchor and therefore
383/// every other alias pointing at it, so
384/// [`try_set_path`](YamlPath::try_set_path) refuses by default and the
385/// caller has to pick a policy deliberately.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
387pub enum AliasPolicy {
388    /// Refuse to descend, reporting [`PathError::AliasRefused`].
389    ///
390    /// The default for writes.
391    #[default]
392    Refuse,
393    /// Follow the alias to its anchored node.
394    ///
395    /// For reads this is plain resolution. For writes it mutates the
396    /// anchored node in place, so the change is visible through the
397    /// anchor and through every other alias that references it.
398    Follow,
399    /// Replace the alias with an independent copy of the anchored node,
400    /// then operate on that copy.
401    ///
402    /// Only meaningful for writes; reads treat it like
403    /// [`Follow`](Self::Follow), since a read has nothing to write back.
404    /// The copy does not carry the original `&anchor`, so other aliases
405    /// keep pointing at the untouched original.
406    Expand,
407}
408
409impl std::fmt::Display for PathError {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        match self {
412            PathError::Parse(e) => write!(f, "path parse error: {e}"),
413            PathError::EmptyPath => write!(f, "path is empty (no segments)"),
414            PathError::NoRoot => write!(f, "document has no root to descend into"),
415            PathError::TypeMismatch { at } => {
416                write!(f, "path segment {at:?} is not a container")
417            }
418            PathError::NotFound { at } => write!(f, "path segment {at:?} not found"),
419            PathError::AliasRefused { at, alias } => write!(
420                f,
421                "path segment {at:?} is an alias to {alias:?}; refusing to descend through it"
422            ),
423            PathError::IndexTooFar { at, len, index } => write!(
424                f,
425                "path segment {at:?} would grow a sequence of {len} by {} null entries to reach index {index}",
426                index + 1 - len
427            ),
428            PathError::UndefinedAlias { at, alias } => write!(
429                f,
430                "path segment {at:?} is an alias to undefined anchor {alias:?}"
431            ),
432        }
433    }
434}
435
436impl std::error::Error for PathError {}
437
438impl From<PathParseError> for PathError {
439    fn from(e: PathParseError) -> Self {
440        PathError::Parse(e)
441    }
442}
443
444/// Parse a path string into components.
445///
446/// Supports multiple syntaxes:
447/// - Dot notation: `"server.host"` → `[Key("server"), Key("host")]`
448/// - Array indices with brackets: `"items[0].name"` → `[Key("items"), Index(0), Key("name")]`
449/// - Array indices with dots: `"items.0.name"` → `[Key("items"), Index(0), Key("name")]`
450/// - Escaped dots: `"key\\.with\\.dots"` → `[Key("key.with.dots")]`
451///
452/// An empty path returns an empty list. A malformed `[...]` index is an
453/// error (unclosed `]` or a non-`usize` index).
454///
455/// # Examples
456///
457/// ```
458/// use yaml_edit::path::{try_parse_path, PathSegment};
459///
460/// let segments = try_parse_path("server.host").unwrap();
461/// assert_eq!(segments, vec![
462///     PathSegment::Key("server".to_string()),
463///     PathSegment::Key("host".to_string())
464/// ]);
465///
466/// let segments = try_parse_path("items[0].name").unwrap();
467/// assert_eq!(segments, vec![
468///     PathSegment::Key("items".to_string()),
469///     PathSegment::Index(0),
470///     PathSegment::Key("name".to_string())
471/// ]);
472///
473/// let segments = try_parse_path("items.0").unwrap();
474/// assert_eq!(segments, vec![
475///     PathSegment::Key("items".to_string()),
476///     PathSegment::Index(0)
477/// ]);
478/// ```
479///
480/// # Errors
481///
482/// Returns [`PathParseError::UnclosedIndex`] when a `[` has no matching `]`,
483/// or [`PathParseError::InvalidIndex`] when the brackets do not contain a
484/// `usize`.
485pub fn try_parse_path(path: &str) -> Result<Vec<PathSegment>, PathParseError> {
486    if path.is_empty() {
487        return Ok(vec![]);
488    }
489
490    let mut segments = Vec::new();
491    let mut current = String::new();
492    let mut chars = path.chars().peekable();
493    let mut escaped = false;
494
495    while let Some(ch) = chars.next() {
496        if escaped {
497            // Previous character was backslash, add this character literally
498            current.push(ch);
499            escaped = false;
500            continue;
501        }
502
503        match ch {
504            '\\' => {
505                // Escape next character
506                escaped = true;
507            }
508            '.' => {
509                // Segment separator
510                if !current.is_empty() {
511                    // Check if current segment is a number (for array index notation like "items.0")
512                    if let Ok(index) = current.parse::<usize>() {
513                        segments.push(PathSegment::Index(index));
514                    } else {
515                        segments.push(PathSegment::Key(current.clone()));
516                    }
517                    current.clear();
518                }
519            }
520            '[' => {
521                // Array index with bracket notation
522                if !current.is_empty() {
523                    segments.push(PathSegment::Key(current.clone()));
524                    current.clear();
525                }
526
527                // Parse the index until we hit ']'
528                let mut index_str = String::new();
529                let mut closed = false;
530                while let Some(&next_ch) = chars.peek() {
531                    if next_ch == ']' {
532                        chars.next(); // consume the ']'
533                        closed = true;
534                        break;
535                    }
536                    index_str.push(chars.next().unwrap());
537                }
538
539                if !closed {
540                    return Err(PathParseError::UnclosedIndex);
541                }
542                match index_str.parse::<usize>() {
543                    Ok(index) => segments.push(PathSegment::Index(index)),
544                    Err(_) => return Err(PathParseError::InvalidIndex(index_str)),
545                }
546            }
547            _ => {
548                current.push(ch);
549            }
550        }
551    }
552
553    // Add the last segment
554    if !current.is_empty() {
555        if let Ok(index) = current.parse::<usize>() {
556            segments.push(PathSegment::Index(index));
557        } else {
558            segments.push(PathSegment::Key(current));
559        }
560    }
561
562    Ok(segments)
563}
564
565/// Parse a path string into components.
566///
567/// Invalid bracket indexes used to return an empty `Vec`, which callers
568/// could not tell from a real empty path. Prefer [`try_parse_path`].
569#[deprecated(note = "use try_parse_path; a bad [index] used to return an empty Vec")]
570pub fn parse_path(path: &str) -> Vec<PathSegment> {
571    try_parse_path(path).unwrap_or_default()
572}
573
574/// Parse `path` into a non-empty segment list, mapping the two
575/// failure modes ("unparseable" and "empty") to distinct `PathError`
576/// variants.
577fn path_segments_required(path: &str) -> Result<Vec<PathSegment>, PathError> {
578    let segments = try_parse_path(path)?;
579    if segments.is_empty() {
580        Err(PathError::EmptyPath)
581    } else {
582        Ok(segments)
583    }
584}
585
586/// Format a segment for use in a `PathError::TypeMismatch` / `NotFound`
587/// message. Mirrors the input syntax the user would type.
588fn segment_display(segment: &PathSegment) -> String {
589    match segment {
590        PathSegment::Key(key) => key.clone(),
591        PathSegment::Index(index) => format!("[{index}]"),
592    }
593}
594
595/// Resolve a node to the mapping it addresses, looking through a tag.
596///
597/// A tagged collection is a mapping or sequence wearing a tag, so a path
598/// segment descends into the inner collection and leaves the tag in place.
599fn node_as_mapping(node: &crate::as_yaml::YamlNode) -> Option<Mapping> {
600    match node {
601        crate::as_yaml::YamlNode::Mapping(m) => Some(m.clone()),
602        crate::as_yaml::YamlNode::TaggedNode(t) => t.as_mapping(),
603        _ => None,
604    }
605}
606
607/// Resolve a node to the sequence it addresses, looking through a tag.
608/// See [`node_as_mapping`].
609fn node_as_sequence(node: &crate::as_yaml::YamlNode) -> Option<crate::yaml::Sequence> {
610    match node {
611        crate::as_yaml::YamlNode::Sequence(s) => Some(s.clone()),
612        crate::as_yaml::YamlNode::TaggedNode(t) => t.as_sequence(),
613        _ => None,
614    }
615}
616
617/// Whether an existing value may be replaced by a container the setter
618/// creates. Only a bare scalar may: a tagged scalar, an alias, or a
619/// collection of the wrong shape is user data we refuse to overwrite.
620fn is_replaceable_scalar(node: &crate::as_yaml::YamlNode) -> bool {
621    matches!(node, crate::as_yaml::YamlNode::Scalar(_))
622}
623
624/// Navigate through a YAML structure following path segments.
625///
626/// Handles both mapping keys and sequence indices. A numeric segment
627/// like `997` in `foo.997` parses as `Index(997)` even though the user
628/// may have meant it as a mapping key: when the current node is a
629/// mapping, fall back to looking up the stringified form. Errors
630/// distinguish "wrong container type" from "key/index not present".
631fn navigate_path(
632    mut current: crate::as_yaml::YamlNode,
633    segments: &[PathSegment],
634    policy: AliasPolicy,
635) -> Result<crate::as_yaml::YamlNode, PathError> {
636    for segment in segments {
637        current = descend_one(current, segment, policy)?;
638    }
639    Ok(current)
640}
641
642/// Build an anchor registry covering the whole tree `node` belongs to,
643/// so aliases anywhere in the document resolve even when traversal
644/// started from a nested `Mapping`.
645fn registry_for(node: &crate::as_yaml::YamlNode) -> crate::anchor_resolution::AnchorRegistry {
646    let root = node
647        .syntax()
648        .ancestors()
649        .last()
650        .unwrap_or_else(|| node.syntax().clone());
651    crate::anchor_resolution::AnchorRegistry::from_tree(&root)
652}
653
654/// Resolve `current` if it is an alias, according to `policy`.
655///
656/// `segment` is only used to label errors: it is the segment that
657/// produced `current`, i.e. the one the caller is about to descend
658/// through.
659fn resolve_for_read(
660    current: crate::as_yaml::YamlNode,
661    segment: &PathSegment,
662    policy: AliasPolicy,
663) -> Result<crate::as_yaml::YamlNode, PathError> {
664    let crate::as_yaml::YamlNode::Alias(alias) = &current else {
665        return Ok(current);
666    };
667    let name = alias.name();
668
669    if policy == AliasPolicy::Refuse {
670        return Err(PathError::AliasRefused {
671            at: segment_display(segment),
672            alias: name,
673        });
674    }
675
676    let registry = registry_for(&current);
677    let target = registry
678        .resolve(&name)
679        .cloned()
680        .ok_or_else(|| PathError::UndefinedAlias {
681            at: segment_display(segment),
682            alias: name.clone(),
683        })?;
684
685    crate::as_yaml::YamlNode::from_syntax_peeled(target).ok_or(PathError::UndefinedAlias {
686        at: segment_display(segment),
687        alias: name,
688    })
689}
690
691/// Look up `key` in `mapping`, falling back to the keys it inherits
692/// through `<<:` merge keys.
693///
694/// A direct entry always wins: YAML 1.1 says a mapping's own key
695/// shadows one merged in from an anchor.
696fn lookup_merged(mapping: &Mapping, key: &str) -> Option<crate::as_yaml::YamlNode> {
697    if let Some(found) = mapping.get(key) {
698        return Some(found);
699    }
700    use crate::anchor_resolution::MappingMergedExt;
701    let registry = registry_for(&crate::as_yaml::YamlNode::Mapping(mapping.clone()));
702    mapping.merged(&registry).get(key)
703}
704
705fn descend_one(
706    current: crate::as_yaml::YamlNode,
707    segment: &PathSegment,
708    policy: AliasPolicy,
709) -> Result<crate::as_yaml::YamlNode, PathError> {
710    let current = resolve_for_read(current, segment, policy)?;
711    match segment {
712        PathSegment::Key(key) => {
713            let mapping = node_as_mapping(&current).ok_or_else(|| PathError::TypeMismatch {
714                at: segment_display(segment),
715            })?;
716            lookup_merged(&mapping, key).ok_or_else(|| PathError::NotFound {
717                at: segment_display(segment),
718            })
719        }
720        PathSegment::Index(index) => {
721            if let Some(seq) = node_as_sequence(&current) {
722                seq.get(*index).ok_or_else(|| PathError::NotFound {
723                    at: segment_display(segment),
724                })
725            } else if let Some(map) = node_as_mapping(&current) {
726                // Fallback: numeric segment used as a mapping key.
727                lookup_merged(&map, &index.to_string()).ok_or_else(|| PathError::NotFound {
728                    at: segment_display(segment),
729                })
730            } else {
731                Err(PathError::TypeMismatch {
732                    at: segment_display(segment),
733                })
734            }
735        }
736    }
737}
738
739/// Interpret a segment as a mapping key. `Index(n)` is stringified so
740/// paths like `foo.997` (parsed as `foo` + `Index(997)`) still address
741/// a mapping key `"997"`.
742fn segment_key(segment: &PathSegment) -> String {
743    match segment {
744        PathSegment::Key(key) => key.clone(),
745        PathSegment::Index(index) => index.to_string(),
746    }
747}
748
749// Implementation for Document
750impl YamlPath for crate::yaml::Document {
751    fn try_get_path_with(
752        &self,
753        path: &str,
754        policy: AliasPolicy,
755    ) -> Result<crate::as_yaml::YamlNode, PathError> {
756        let segments = path_segments_required(path)?;
757
758        // Start from the document's root content
759        let root = if let Some(m) = self.as_mapping() {
760            crate::as_yaml::YamlNode::Mapping(m)
761        } else if let Some(s) = self.as_sequence() {
762            crate::as_yaml::YamlNode::Sequence(s)
763        } else if let Some(sc) = self.as_scalar() {
764            crate::as_yaml::YamlNode::Scalar(sc)
765        } else {
766            return Err(PathError::NoRoot);
767        };
768
769        navigate_path(root, &segments, policy)
770    }
771
772    fn try_set_path_with(
773        &self,
774        path: &str,
775        value: impl crate::AsYaml,
776        policy: AliasPolicy,
777    ) -> Result<(), PathError> {
778        let segments = path_segments_required(path)?;
779
780        // Only a root mapping can hold new-key insertions; a scalar or
781        // sequence root has no place to graft `foo.bar` under.
782        let mapping = self.as_mapping().ok_or(PathError::NoRoot)?;
783
784        set_path_on_mapping(&mapping, &segments, value, policy)
785    }
786
787    fn try_remove_path_with(
788        &self,
789        path: &str,
790        policy: AliasPolicy,
791    ) -> Result<crate::as_yaml::YamlNode, PathError> {
792        let segments = path_segments_required(path)?;
793
794        let root = if let Some(m) = self.as_mapping() {
795            crate::as_yaml::YamlNode::Mapping(m)
796        } else if let Some(s) = self.as_sequence() {
797            crate::as_yaml::YamlNode::Sequence(s)
798        } else {
799            return Err(PathError::NoRoot);
800        };
801
802        remove_path_impl(root, &segments, policy)
803    }
804}
805
806/// Remove a value at a nested path. Returns the removed
807/// [`YamlNode`](crate::as_yaml::YamlNode) on success. Errors carry the
808/// specific reason (type mismatch vs. not-found).
809///
810/// Removing by index from a sequence is intentionally unsupported (it
811/// would shift every subsequent element) and reported as a
812/// `PathError::TypeMismatch` at that segment.
813fn remove_path_impl(
814    root: crate::as_yaml::YamlNode,
815    segments: &[PathSegment],
816    policy: AliasPolicy,
817) -> Result<crate::as_yaml::YamlNode, PathError> {
818    debug_assert!(!segments.is_empty(), "caller must reject empty paths");
819
820    // The node we were handed may itself be an alias (e.g. `a: *shared`
821    // then removing `a.timeout`). Resolving here means the final
822    // container lookup below sees the anchored mapping.
823    let root = resolve_for_read(root, &segments[0], policy)?;
824
825    if segments.len() == 1 {
826        let seg = &segments[0];
827        let key = match seg {
828            PathSegment::Key(key) => key.clone(),
829            PathSegment::Index(index) => {
830                // Numeric segment on a mapping falls back to the
831                // stringified key. On a sequence, index removal is
832                // unsupported.
833                if node_as_mapping(&root).is_none() {
834                    return Err(PathError::TypeMismatch {
835                        at: segment_display(seg),
836                    });
837                }
838                index.to_string()
839            }
840        };
841        let mapping = node_as_mapping(&root).ok_or_else(|| PathError::TypeMismatch {
842            at: segment_display(seg),
843        })?;
844        // Grab the value before removal so we can return it. If the
845        // entry has no VALUE child (unusual: implicit-null entry), fall
846        // back to a null scalar.
847        let value = mapping
848            .get(key.as_str())
849            .ok_or_else(|| PathError::NotFound {
850                at: segment_display(seg),
851            })?;
852        mapping.remove(key.as_str());
853        return Ok(value);
854    }
855
856    // Descend one level and recurse.
857    let nested = descend_one(root, &segments[0], policy)?;
858    remove_path_impl(nested, &segments[1..], policy)
859}
860
861// Implementation for Mapping
862impl YamlPath for Mapping {
863    fn try_get_path_with(
864        &self,
865        path: &str,
866        policy: AliasPolicy,
867    ) -> Result<crate::as_yaml::YamlNode, PathError> {
868        let segments = path_segments_required(path)?;
869        navigate_path(
870            crate::as_yaml::YamlNode::Mapping(self.clone()),
871            &segments,
872            policy,
873        )
874    }
875
876    fn try_set_path_with(
877        &self,
878        path: &str,
879        value: impl crate::AsYaml,
880        policy: AliasPolicy,
881    ) -> Result<(), PathError> {
882        let segments = path_segments_required(path)?;
883        set_path_on_mapping(self, &segments, value, policy)
884    }
885
886    fn try_remove_path_with(
887        &self,
888        path: &str,
889        policy: AliasPolicy,
890    ) -> Result<crate::as_yaml::YamlNode, PathError> {
891        let segments = path_segments_required(path)?;
892        remove_path_impl(
893            crate::as_yaml::YamlNode::Mapping(self.clone()),
894            &segments,
895            policy,
896        )
897    }
898}
899
900/// Resolve an alias encountered as an intermediate segment of a write.
901///
902/// Under [`AliasPolicy::Refuse`] this is an error. Under
903/// [`AliasPolicy::Follow`] it returns the anchored node itself, so
904/// writes land on the shared value. Under [`AliasPolicy::Expand`] it
905/// returns a detached copy of the anchored node, which the caller
906/// substitutes for the alias; the copy is rebuilt as its own tree root
907/// so it does not carry the original `&anchor` along.
908fn resolve_alias_for_write(
909    alias: &crate::yaml::Alias,
910    segment: &PathSegment,
911    policy: AliasPolicy,
912) -> Result<crate::as_yaml::YamlNode, PathError> {
913    use rowan::ast::AstNode;
914
915    let name = alias.name();
916    if policy == AliasPolicy::Refuse {
917        return Err(PathError::AliasRefused {
918            at: segment_display(segment),
919            alias: name,
920        });
921    }
922
923    let undefined = || PathError::UndefinedAlias {
924        at: segment_display(segment),
925        alias: name.clone(),
926    };
927
928    let root = alias
929        .syntax()
930        .ancestors()
931        .last()
932        .unwrap_or_else(|| alias.syntax().clone());
933    let target = crate::anchor_resolution::AnchorRegistry::from_tree(&root)
934        .resolve(&name)
935        .cloned()
936        .ok_or_else(undefined)?;
937
938    let target = if policy == AliasPolicy::Expand {
939        // Detaching drops the `&anchor` that sits beside the original in
940        // its parent VALUE, so the copy is a plain value.
941        crate::yaml::SyntaxNode::new_root_mut(target.green().into_owned())
942    } else {
943        target
944    };
945
946    crate::as_yaml::YamlNode::from_syntax_peeled(target).ok_or_else(undefined)
947}
948
949/// Continue a write into `node`, dispatching on whether it is a mapping
950/// or a sequence. `via` is the segment that produced `node`, used only
951/// to label a type mismatch.
952fn set_path_on_node<V: crate::AsYaml>(
953    node: crate::as_yaml::YamlNode,
954    segments: &[PathSegment],
955    value: V,
956    policy: AliasPolicy,
957    via: &PathSegment,
958) -> Result<(), PathError> {
959    if let Some(m) = node.as_mapping() {
960        set_path_on_mapping(m, segments, value, policy)
961    } else if let Some(seq) = node.as_sequence() {
962        set_path_on_sequence(seq, segments, value, policy)
963    } else {
964        Err(PathError::TypeMismatch {
965            at: segment_display(via),
966        })
967    }
968}
969
970/// Set a value at a path on a mapping, creating intermediate mappings or
971/// sequences as needed.
972///
973/// Uses only the public API (get_mapping, get_sequence, set) and does NOT
974/// rebuild nodes. Recurses through `set_path_on_sequence` when a segment
975/// dives into a sequence.
976///
977/// Errors when an intermediate segment lands on an existing scalar (we
978/// won't overwrite user data implicitly).
979fn set_path_on_mapping<V: crate::AsYaml>(
980    mapping: &Mapping,
981    segments: &[PathSegment],
982    value: V,
983    policy: AliasPolicy,
984) -> Result<(), PathError> {
985    debug_assert!(!segments.is_empty(), "caller must reject empty paths");
986
987    // First segment: numeric segments (from `foo.997`) are stringified
988    // so they still address a mapping key.
989    let first_key_owned = segment_key(&segments[0]);
990    let first_key = first_key_owned.as_str();
991
992    if segments.len() == 1 {
993        // Base case: set directly. Replacing an alias outright is not
994        // writing *through* it -- no other alias is affected -- so it
995        // needs no policy check.
996        mapping.set(first_key, value);
997        return Ok(());
998    }
999
1000    // An intermediate alias: either refuse, follow it into the anchored
1001    // node, or replace it here with an anchor-free copy and continue in
1002    // that copy.
1003    if let Some(crate::as_yaml::YamlNode::Alias(alias)) = mapping.get(first_key) {
1004        let resolved = resolve_alias_for_write(&alias, &segments[0], policy)?;
1005        let target = if policy == AliasPolicy::Expand {
1006            mapping.set(first_key, resolved);
1007            // Re-read through the mapping so we descend into the copy
1008            // that now lives in the tree, not the detached one.
1009            mapping
1010                .get(first_key)
1011                .ok_or_else(|| PathError::TypeMismatch {
1012                    at: segment_display(&segments[0]),
1013                })?
1014        } else {
1015            resolved
1016        };
1017        return set_path_on_node(target, &segments[1..], value, policy, &segments[0]);
1018    }
1019
1020    // What container does the next segment expect at `first_key`?
1021    let next_wants_sequence = matches!(segments[1], PathSegment::Index(_));
1022
1023    // Reject descending through a scalar. `get(first_key)` returns the
1024    // existing value if any; if it's a non-null scalar we'd have to
1025    // overwrite user data to continue. Null placeholders are treated
1026    // as "vacant" and get replaced by the appropriate container.
1027    if let Some(existing) = mapping.get(first_key) {
1028        if let Some(sc) = existing.as_scalar() {
1029            let s = sc.as_string();
1030            let is_null_placeholder = s.is_empty() || s.eq_ignore_ascii_case("null") || s == "~";
1031            if !is_null_placeholder {
1032                return Err(PathError::TypeMismatch {
1033                    at: segment_display(&segments[0]),
1034                });
1035            }
1036        }
1037    }
1038
1039    let existing = mapping.get(first_key);
1040
1041    if next_wants_sequence {
1042        if let Some(nested) = existing.as_ref().and_then(node_as_sequence) {
1043            return set_path_on_sequence(&nested, &segments[1..], value, policy);
1044        }
1045        // Index on an existing mapping is a key (`m.0` / `m[0]`), same
1046        // as get_path. Do not replace the mapping with a sequence.
1047        if let Some(nested) = existing.as_ref().and_then(node_as_mapping) {
1048            return set_path_on_mapping(&nested, &segments[1..], value, policy);
1049        }
1050        if existing.as_ref().is_some_and(|n| !is_replaceable_scalar(n)) {
1051            return Err(PathError::TypeMismatch {
1052                at: segment_display(&segments[0]),
1053            });
1054        }
1055        // A sequence created here starts empty, so an index past the
1056        // growth bound is known to fail. Say so before writing anything:
1057        // creating the key first left `k:\n  \n` behind, a half-built
1058        // entry that no longer reparses as the sequence it claimed.
1059        if let PathSegment::Index(index) = segments[1] {
1060            if index >= MAX_INDEX_GROWTH {
1061                return Err(PathError::IndexTooFar {
1062                    at: segment_display(&segments[1]),
1063                    len: 0,
1064                    index,
1065                });
1066            }
1067        }
1068        // Match the parent's style so we don't mix block content into a flow
1069        // container.
1070        if mapping.is_flow_style() {
1071            mapping.set(first_key, crate::yaml::Sequence::new_flow());
1072        } else {
1073            mapping.set(first_key, crate::yaml::Sequence::new_pending_block());
1074        }
1075        let nested = mapping
1076            .get_sequence(first_key)
1077            .expect("we just inserted this key as a sequence");
1078        return set_path_on_sequence(&nested, &segments[1..], value, policy);
1079    }
1080
1081    if let Some(nested) = existing.as_ref().and_then(node_as_mapping) {
1082        return set_path_on_mapping(&nested, &segments[1..], value, policy);
1083    }
1084    if existing.as_ref().is_some_and(|n| !is_replaceable_scalar(n)) {
1085        return Err(PathError::TypeMismatch {
1086            at: segment_display(&segments[0]),
1087        });
1088    }
1089
1090    // Match the parent's style so we don't mix block content into a flow
1091    // container.
1092    if mapping.is_flow_style() {
1093        mapping.set(first_key, Mapping::new_flow());
1094    } else {
1095        mapping.set(first_key, Mapping::new_pending_block());
1096    }
1097
1098    let nested = mapping
1099        .get_mapping(first_key)
1100        .expect("we just inserted this key as a mapping");
1101    set_path_on_mapping(&nested, &segments[1..], value, policy)
1102}
1103
1104/// Set a value at a path on a sequence, growing it and creating intermediate
1105/// containers as needed.
1106///
1107/// The segments slice must start with an `Index`. Missing entries up to
1108/// `index` are pushed as null scalars; the target entry is replaced (single-
1109/// segment path) or descended into (multi-segment path).
1110fn set_path_on_sequence<V: crate::AsYaml>(
1111    sequence: &crate::yaml::Sequence,
1112    segments: &[PathSegment],
1113    value: V,
1114    policy: AliasPolicy,
1115) -> Result<(), PathError> {
1116    debug_assert!(!segments.is_empty(), "caller must reject empty paths");
1117
1118    let index = match &segments[0] {
1119        PathSegment::Index(i) => *i,
1120        // A string segment on a sequence is a real type mismatch: we
1121        // have no reasonable coercion to try (unlike numeric-on-mapping,
1122        // which stringifies).
1123        PathSegment::Key(_) => {
1124            return Err(PathError::TypeMismatch {
1125                at: segment_display(&segments[0]),
1126            });
1127        }
1128    };
1129
1130    // Grow the sequence with null placeholders until `index` is in range,
1131    // within reason: an index far past the end would otherwise let a single
1132    // path allocate unboundedly.
1133    let len = sequence.len();
1134    // A single sequence edit is linear in its length, so pad in one splice
1135    // rather than one push per placeholder.
1136    if index >= len && index - len >= MAX_INDEX_GROWTH {
1137        return Err(PathError::IndexTooFar {
1138            at: segment_display(&segments[0]),
1139            len,
1140            index,
1141        });
1142    }
1143    if let Some(growth) = (index + 1).checked_sub(len) {
1144        sequence.extend_with(crate::scalar::ScalarValue::null(), growth);
1145    }
1146
1147    if segments.len() == 1 {
1148        // Replacing an alias entry outright affects no other alias, so
1149        // no policy check here (mirrors the mapping base case).
1150        sequence.set(index, value);
1151        return Ok(());
1152    }
1153
1154    // Same alias handling as the mapping case: refuse, follow into the
1155    // anchored node, or swap in an anchor-free copy and descend there.
1156    if let Some(crate::as_yaml::YamlNode::Alias(alias)) = sequence.get(index) {
1157        let resolved = resolve_alias_for_write(&alias, &segments[0], policy)?;
1158        let target = if policy == AliasPolicy::Expand {
1159            sequence.set(index, resolved);
1160            sequence.get(index).ok_or_else(|| PathError::TypeMismatch {
1161                at: segment_display(&segments[0]),
1162            })?
1163        } else {
1164            resolved
1165        };
1166        return set_path_on_node(target, &segments[1..], value, policy, &segments[0]);
1167    }
1168
1169    // Descending through an existing scalar is fine when that scalar is
1170    // a null placeholder (either one we just pushed to grow the
1171    // sequence, or an existing `null` the user chose). Reject only when
1172    // we'd have to overwrite a non-null user value.
1173    if let Some(existing) = sequence.get(index) {
1174        if let Some(sc) = existing.as_scalar() {
1175            let s = sc.as_string();
1176            let is_null_placeholder = s.is_empty() || s.eq_ignore_ascii_case("null") || s == "~";
1177            if !is_null_placeholder {
1178                return Err(PathError::TypeMismatch {
1179                    at: segment_display(&segments[0]),
1180                });
1181            }
1182        }
1183    }
1184
1185    let next_wants_sequence = matches!(segments[1], PathSegment::Index(_));
1186    let existing = sequence.get(index);
1187
1188    if next_wants_sequence {
1189        if let Some(nested) = existing.as_ref().and_then(node_as_sequence) {
1190            return set_path_on_sequence(&nested, &segments[1..], value, policy);
1191        }
1192        if let Some(nested) = existing.as_ref().and_then(node_as_mapping) {
1193            return set_path_on_mapping(&nested, &segments[1..], value, policy);
1194        }
1195        if existing.as_ref().is_some_and(|n| !is_replaceable_scalar(n)) {
1196            return Err(PathError::TypeMismatch {
1197                at: segment_display(&segments[0]),
1198            });
1199        }
1200        // Nested-sequence-under-sequence: use SequenceBuilder to
1201        // create a flow-empty `[]`. A block SEQUENCE nested inline
1202        // after `- ` renders as a compact-block shape that re-parses
1203        // as a plain scalar, so we need to keep the inner sequence
1204        // flow. push/insert see `must_render_flow` and preserve
1205        // that flow style through the subsequent set.
1206        let flow_empty = crate::builder::SequenceBuilder::new()
1207            .build_document()
1208            .as_sequence()
1209            .expect("SequenceBuilder always produces a sequence");
1210        sequence.set(index, flow_empty);
1211        let nested = sequence
1212            .get(index)
1213            .and_then(|n| n.as_sequence().cloned())
1214            .expect("we just inserted a sequence at this index");
1215        return set_path_on_sequence(&nested, &segments[1..], value, policy);
1216    }
1217
1218    if let Some(nested) = existing.as_ref().and_then(node_as_mapping) {
1219        return set_path_on_mapping(&nested, &segments[1..], value, policy);
1220    }
1221    if existing.as_ref().is_some_and(|n| !is_replaceable_scalar(n)) {
1222        return Err(PathError::TypeMismatch {
1223            at: segment_display(&segments[0]),
1224        });
1225    }
1226    let flow_empty = crate::builder::MappingBuilder::new()
1227        .build_document()
1228        .as_mapping()
1229        .expect("MappingBuilder always produces a mapping");
1230    sequence.set(index, flow_empty);
1231    let nested = sequence
1232        .get(index)
1233        .and_then(|n| n.as_mapping().cloned())
1234        .expect("we just inserted a mapping at this index");
1235    set_path_on_mapping(&nested, &segments[1..], value, policy)
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241
1242    #[test]
1243    fn set_path_bounds_sequence_growth() {
1244        use crate::Document;
1245        use std::str::FromStr;
1246
1247        // Filling the gap to an out-of-range index used to be unbounded, so
1248        // a single path could build an enormous document: the fuzz-found
1249        // "999.hh.hh.999.9aa" spent ~2s producing 13KB from a 6-byte seed.
1250        let doc = Document::from_str("m: {}\n").unwrap();
1251        let err = doc
1252            .try_set_path("999.hh.hh.999.9aa", "")
1253            .expect_err("a far index must be refused");
1254        assert!(
1255            matches!(err, PathError::IndexTooFar { .. }),
1256            "unexpected error: {err}"
1257        );
1258        // Nothing was written on the way to refusing.
1259        assert!(doc.to_string().len() < 64, "{:?}", doc.to_string());
1260
1261        // Growth within the limit still works: writing s[5] on an empty
1262        // sequence is a normal way to build one.
1263        let doc = Document::from_str("m: {}\n").unwrap();
1264        doc.try_set_path("s[5]", "v").expect("small index is fine");
1265        assert_eq!(doc.try_get_path("s[5]").unwrap().to_string(), "v");
1266
1267        let doc = Document::from_str("m: {}\n").unwrap();
1268        assert!(matches!(
1269            doc.try_set_path(&format!("s[{MAX_INDEX_GROWTH}]"), "v"),
1270            Err(PathError::IndexTooFar { .. })
1271        ));
1272    }
1273
1274    fn merge_doc() -> crate::yaml::YamlFile {
1275        use std::str::FromStr;
1276        crate::yaml::YamlFile::from_str(
1277            "base: &b\n  a: 1\n  shared: x\nderived:\n  <<: *b\n  own: 2\n  a: 99\n",
1278        )
1279        .unwrap()
1280    }
1281
1282    #[test]
1283    fn get_path_resolves_merged_keys() {
1284        let file = merge_doc();
1285        let doc = file.document().unwrap();
1286
1287        // Inherited through `<<: *b`.
1288        assert_eq!(doc.try_get_path("derived.shared").unwrap().to_string(), "x");
1289        // A local entry still wins over the merged one.
1290        assert_eq!(doc.try_get_path("derived.a").unwrap().to_string(), "99");
1291        // Plain local key, and the anchor's own mapping, are unaffected.
1292        assert_eq!(doc.try_get_path("derived.own").unwrap().to_string(), "2");
1293        assert_eq!(doc.try_get_path("base.a").unwrap().to_string(), "1");
1294    }
1295
1296    #[test]
1297    fn get_path_still_reports_genuinely_absent_keys() {
1298        let file = merge_doc();
1299        let doc = file.document().unwrap();
1300        assert!(doc.try_get_path("derived.nope").is_err());
1301    }
1302
1303    #[test]
1304    fn remove_path_stays_literal_for_merged_keys() {
1305        // An inherited key lives in the anchor, not in the mapping being
1306        // walked, so it must not look removable from `derived`.
1307        let file = merge_doc();
1308        let before = file.to_string();
1309        let doc = file.document().unwrap();
1310        assert!(doc.try_remove_path("derived.shared").is_err());
1311        assert_eq!(file.to_string(), before);
1312    }
1313
1314    #[test]
1315    fn test_parse_path_basic() {
1316        assert_eq!(try_parse_path("").unwrap(), Vec::<PathSegment>::new());
1317        assert_eq!(
1318            try_parse_path("key").unwrap(),
1319            vec![PathSegment::Key("key".to_string())]
1320        );
1321        assert_eq!(
1322            try_parse_path("a.b").unwrap(),
1323            vec![
1324                PathSegment::Key("a".to_string()),
1325                PathSegment::Key("b".to_string())
1326            ]
1327        );
1328        assert_eq!(
1329            try_parse_path("a.b.c.d").unwrap(),
1330            vec![
1331                PathSegment::Key("a".to_string()),
1332                PathSegment::Key("b".to_string()),
1333                PathSegment::Key("c".to_string()),
1334                PathSegment::Key("d".to_string())
1335            ]
1336        );
1337    }
1338
1339    #[test]
1340    fn test_try_parse_path_invalid_brackets() {
1341        assert_eq!(
1342            try_parse_path("items[abc].name"),
1343            Err(PathParseError::InvalidIndex("abc".to_string()))
1344        );
1345        assert_eq!(
1346            try_parse_path("items[].name"),
1347            Err(PathParseError::InvalidIndex(String::new()))
1348        );
1349        assert_eq!(
1350            try_parse_path("items[0"),
1351            Err(PathParseError::UnclosedIndex)
1352        );
1353    }
1354
1355    #[test]
1356    #[allow(deprecated)]
1357    fn test_parse_path_deprecated_wrapper_empty_on_bad_index() {
1358        assert_eq!(parse_path("items[abc].name"), Vec::<PathSegment>::new());
1359        assert_eq!(parse_path("items[0"), Vec::<PathSegment>::new());
1360    }
1361
1362    #[test]
1363    fn test_parse_path_with_array_indices() {
1364        assert_eq!(
1365            try_parse_path("items[0]").unwrap(),
1366            vec![PathSegment::Key("items".to_string()), PathSegment::Index(0)]
1367        );
1368        assert_eq!(
1369            try_parse_path("items[0].name").unwrap(),
1370            vec![
1371                PathSegment::Key("items".to_string()),
1372                PathSegment::Index(0),
1373                PathSegment::Key("name".to_string())
1374            ]
1375        );
1376        assert_eq!(
1377            try_parse_path("data.items[5].value").unwrap(),
1378            vec![
1379                PathSegment::Key("data".to_string()),
1380                PathSegment::Key("items".to_string()),
1381                PathSegment::Index(5),
1382                PathSegment::Key("value".to_string())
1383            ]
1384        );
1385    }
1386
1387    #[test]
1388    fn test_parse_path_with_numeric_indices() {
1389        assert_eq!(
1390            try_parse_path("items.0").unwrap(),
1391            vec![PathSegment::Key("items".to_string()), PathSegment::Index(0)]
1392        );
1393        assert_eq!(
1394            try_parse_path("items.0.name").unwrap(),
1395            vec![
1396                PathSegment::Key("items".to_string()),
1397                PathSegment::Index(0),
1398                PathSegment::Key("name".to_string())
1399            ]
1400        );
1401    }
1402
1403    #[test]
1404    fn test_parse_path_with_escaping() {
1405        assert_eq!(
1406            try_parse_path("key\\.with\\.dots").unwrap(),
1407            vec![PathSegment::Key("key.with.dots".to_string())]
1408        );
1409        assert_eq!(
1410            try_parse_path("a.key\\.with\\.dots.b").unwrap(),
1411            vec![
1412                PathSegment::Key("a".to_string()),
1413                PathSegment::Key("key.with.dots".to_string()),
1414                PathSegment::Key("b".to_string())
1415            ]
1416        );
1417    }
1418
1419    #[test]
1420    fn test_get_path_with_array_index() {
1421        use crate::yaml::Document;
1422        use std::str::FromStr;
1423
1424        let yaml = r#"
1425items:
1426  - name: first
1427    value: 1
1428  - name: second
1429    value: 2
1430"#;
1431        let doc = Document::from_str(yaml).unwrap();
1432
1433        // Test bracket notation
1434        let name = doc.try_get_path("items[0].name").ok();
1435        assert_eq!(
1436            name.as_ref()
1437                .and_then(|v| v.as_scalar())
1438                .map(|s| s.as_string()),
1439            Some("first".to_string())
1440        );
1441
1442        let value = doc.try_get_path("items[1].value").ok();
1443        assert_eq!(
1444            value
1445                .as_ref()
1446                .and_then(|v| v.as_scalar())
1447                .map(|s| s.as_string()),
1448            Some("2".to_string())
1449        );
1450    }
1451
1452    #[test]
1453    fn test_get_path_with_numeric_index() {
1454        use crate::yaml::Document;
1455        use std::str::FromStr;
1456
1457        let yaml = r#"
1458items:
1459  - name: first
1460    value: 1
1461  - name: second
1462    value: 2
1463"#;
1464        let doc = Document::from_str(yaml).unwrap();
1465
1466        // Test numeric dot notation
1467        let name = doc.try_get_path("items.0.name").ok();
1468        assert_eq!(
1469            name.as_ref()
1470                .and_then(|v| v.as_scalar())
1471                .map(|s| s.as_string()),
1472            Some("first".to_string())
1473        );
1474
1475        let value = doc.try_get_path("items.1.value").ok();
1476        assert_eq!(
1477            value
1478                .as_ref()
1479                .and_then(|v| v.as_scalar())
1480                .map(|s| s.as_string()),
1481            Some("2".to_string())
1482        );
1483    }
1484
1485    #[test]
1486    fn test_get_path_with_escaping() {
1487        use crate::yaml::Document;
1488
1489        let doc = Document::new();
1490        doc.set("key.with.dots", "test value");
1491
1492        // Without escaping - should not find it (looking for nested keys)
1493        assert!(doc.try_get_path("key.with.dots").is_err());
1494
1495        // With escaping - should find it
1496        let value = doc.try_get_path("key\\.with\\.dots").ok();
1497        assert_eq!(
1498            value
1499                .as_ref()
1500                .and_then(|v| v.as_scalar())
1501                .map(|s| s.as_string()),
1502            Some("test value".to_string())
1503        );
1504    }
1505
1506    #[test]
1507    fn test_get_path_array_only() {
1508        use crate::yaml::Document;
1509        use std::str::FromStr;
1510
1511        let yaml = r#"
1512- first
1513- second
1514- third
1515"#;
1516        let doc = Document::from_str(yaml).unwrap();
1517
1518        // Get from root sequence
1519        let item = doc.try_get_path("0").ok();
1520        assert_eq!(
1521            item.as_ref()
1522                .and_then(|v| v.as_scalar())
1523                .map(|s| s.as_string()),
1524            Some("first".to_string())
1525        );
1526
1527        let item = doc.try_get_path("2").ok();
1528        assert_eq!(
1529            item.as_ref()
1530                .and_then(|v| v.as_scalar())
1531                .map(|s| s.as_string()),
1532            Some("third".to_string())
1533        );
1534    }
1535
1536    #[test]
1537    fn test_remove_path_with_array_index() {
1538        use crate::yaml::Document;
1539        use std::str::FromStr;
1540
1541        let yaml = r#"
1542items:
1543  - name: first
1544    nested:
1545      key: value
1546"#;
1547        let doc = Document::from_str(yaml).unwrap();
1548
1549        // Remove nested key inside array element
1550        assert!(doc.try_remove_path("items[0].nested.key").is_ok());
1551        assert!(doc.try_get_path("items[0].nested.key").is_err());
1552
1553        // The nested mapping should still exist but be empty
1554        assert!(doc.try_get_path("items[0].nested").is_ok());
1555    }
1556
1557    #[test]
1558    fn test_mapping_get_path_with_indices() {
1559        use crate::yaml::Document;
1560        use std::str::FromStr;
1561
1562        let yaml = r#"
1563config:
1564  servers:
1565    - host: server1.com
1566      port: 8080
1567    - host: server2.com
1568      port: 9090
1569"#;
1570        let doc = Document::from_str(yaml).unwrap();
1571        let mapping = doc.as_mapping().unwrap();
1572
1573        // Access through mapping using indices
1574        let host = mapping.try_get_path("config.servers[0].host").ok();
1575        assert_eq!(
1576            host.as_ref()
1577                .and_then(|v| v.as_scalar())
1578                .map(|s| s.as_string()),
1579            Some("server1.com".to_string())
1580        );
1581
1582        let port = mapping.try_get_path("config.servers.1.port").ok();
1583        assert_eq!(
1584            port.as_ref()
1585                .and_then(|v| v.as_scalar())
1586                .map(|s| s.as_string()),
1587            Some("9090".to_string())
1588        );
1589    }
1590
1591    #[test]
1592    fn test_get_path_simple() {
1593        use crate::yaml::Document;
1594        use std::str::FromStr;
1595
1596        let yaml = Document::from_str("name: Alice\nage: 30\n").unwrap();
1597
1598        let name = yaml.try_get_path("name").ok();
1599        assert_eq!(
1600            name.as_ref()
1601                .and_then(|v| v.as_scalar())
1602                .map(|s| s.to_string()),
1603            Some("Alice".to_string())
1604        );
1605
1606        let age = yaml.try_get_path("age").ok();
1607        assert_eq!(
1608            age.as_ref()
1609                .and_then(|v| v.as_scalar())
1610                .map(|s| s.to_string()),
1611            Some("30".to_string())
1612        );
1613    }
1614
1615    #[test]
1616    fn test_get_path_nested() {
1617        use crate::yaml::Document;
1618        use std::str::FromStr;
1619
1620        let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
1621
1622        let host = yaml.try_get_path("server.host").ok();
1623        assert_eq!(
1624            host.as_ref()
1625                .and_then(|v| v.as_scalar())
1626                .map(|s| s.to_string()),
1627            Some("localhost".to_string())
1628        );
1629
1630        let port = yaml.try_get_path("server.port").ok();
1631        assert_eq!(
1632            port.as_ref()
1633                .and_then(|v| v.as_scalar())
1634                .map(|s| s.to_string()),
1635            Some("8080".to_string())
1636        );
1637    }
1638
1639    #[test]
1640    fn test_get_path_deeply_nested() {
1641        use crate::yaml::Document;
1642        use std::str::FromStr;
1643
1644        let yaml = Document::from_str(
1645            "app:\n  database:\n    primary:\n      host: db.example.com\n      port: 5432\n",
1646        )
1647        .unwrap();
1648
1649        let host = yaml.try_get_path("app.database.primary.host").ok();
1650        assert_eq!(
1651            host.as_ref()
1652                .and_then(|v| v.as_scalar())
1653                .map(|s| s.to_string()),
1654            Some("db.example.com".to_string())
1655        );
1656
1657        let port = yaml.try_get_path("app.database.primary.port").ok();
1658        assert_eq!(
1659            port.as_ref()
1660                .and_then(|v| v.as_scalar())
1661                .map(|s| s.to_string()),
1662            Some("5432".to_string())
1663        );
1664    }
1665
1666    #[test]
1667    fn test_get_path_missing() {
1668        use crate::yaml::Document;
1669        use std::str::FromStr;
1670
1671        let yaml = Document::from_str("name: Alice\n").unwrap();
1672
1673        assert!(matches!(
1674            yaml.try_get_path("missing"),
1675            Err(PathError::NotFound { .. })
1676        ));
1677        // `name` is a scalar, `.nested` tries to descend into it.
1678        assert!(matches!(
1679            yaml.try_get_path("name.nested"),
1680            Err(PathError::TypeMismatch { .. })
1681        ));
1682        assert_eq!(yaml.try_get_path(""), Err(PathError::EmptyPath));
1683    }
1684
1685    #[test]
1686    fn test_set_path_existing_key() {
1687        use crate::yaml::Document;
1688        use std::str::FromStr;
1689
1690        let yaml = Document::from_str("name: Alice\nage: 30\n").unwrap();
1691
1692        yaml.try_set_path("name", "Bob").expect("set_path");
1693
1694        assert_eq!(yaml.to_string(), "name: Bob\nage: 30\n");
1695    }
1696
1697    #[test]
1698    fn test_set_path_new_key() {
1699        use crate::yaml::Document;
1700        use std::str::FromStr;
1701
1702        let yaml = Document::from_str("name: Alice\n").unwrap();
1703
1704        yaml.try_set_path("age", 30).expect("set_path");
1705
1706        assert_eq!(yaml.to_string(), "name: Alice\nage: 30\n");
1707    }
1708
1709    #[test]
1710    fn test_set_path_nested_existing() {
1711        use crate::yaml::Document;
1712        use std::str::FromStr;
1713
1714        let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
1715
1716        yaml.try_set_path("server.port", 9000).expect("set_path");
1717
1718        assert_eq!(
1719            yaml.to_string(),
1720            "server:\n  host: localhost\n  port: 9000\n"
1721        );
1722    }
1723
1724    #[test]
1725    fn test_set_path_nested_new() {
1726        use crate::yaml::Document;
1727        use std::str::FromStr;
1728
1729        let yaml = Document::from_str("server:\n  host: localhost\n").unwrap();
1730
1731        yaml.try_set_path("server.port", 8080).expect("set_path");
1732
1733        assert_eq!(
1734            yaml.to_string(),
1735            "server:\n  host: localhost\n  port: 8080\n"
1736        );
1737    }
1738
1739    #[test]
1740    fn test_set_path_create_intermediate() {
1741        use crate::yaml::Document;
1742        use std::str::FromStr;
1743
1744        let yaml = Document::from_str("name: test\n").unwrap();
1745
1746        yaml.try_set_path("server.database.host", "localhost")
1747            .expect("set_path");
1748
1749        assert_eq!(
1750            yaml.to_string(),
1751            "name: test\nserver:\n  database:\n    host: localhost\n"
1752        );
1753
1754        // Verify we can retrieve it
1755        let host = yaml.try_get_path("server.database.host").ok();
1756        assert_eq!(
1757            host.as_ref()
1758                .and_then(|v| v.as_scalar())
1759                .map(|s| s.to_string()),
1760            Some("localhost".to_string())
1761        );
1762    }
1763
1764    #[test]
1765    fn test_set_path_deeply_nested_create() {
1766        use crate::yaml::Document;
1767        use std::str::FromStr;
1768
1769        let yaml = Document::from_str("app: {}\n").unwrap();
1770
1771        yaml.try_set_path("app.database.primary.host", "db.example.com")
1772            .expect("set_path");
1773        yaml.try_set_path("app.database.primary.port", 5432)
1774            .expect("set_path");
1775
1776        // Parent was flow-style, so the whole nested chain stays flow.
1777        assert_eq!(
1778            yaml.to_string().trim(),
1779            r#"app: {database: {primary: {host: "db.example.com", port: 5432}}}"#
1780        );
1781
1782        let host = yaml.try_get_path("app.database.primary.host").ok();
1783        assert_eq!(
1784            host.as_ref()
1785                .and_then(|v| v.as_scalar())
1786                .map(|s| s.as_string()),
1787            Some("db.example.com".to_string())
1788        );
1789
1790        let port = yaml.try_get_path("app.database.primary.port").ok();
1791        assert_eq!(port.as_ref().and_then(|v| v.to_i64()), Some(5432));
1792    }
1793
1794    #[test]
1795    fn test_remove_path_simple() {
1796        use crate::yaml::Document;
1797        use std::str::FromStr;
1798
1799        let yaml = Document::from_str("name: Alice\nage: 30\n").unwrap();
1800
1801        let result = yaml.try_remove_path("age").is_ok();
1802        assert!(result);
1803
1804        assert_eq!(yaml.to_string(), "name: Alice\n");
1805    }
1806
1807    #[test]
1808    fn test_remove_path_nested() {
1809        use crate::yaml::Document;
1810        use std::str::FromStr;
1811
1812        let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
1813
1814        let result = yaml.try_remove_path("server.port").is_ok();
1815        assert!(result);
1816
1817        assert_eq!(yaml.to_string(), "server:\n  host: localhost\n");
1818    }
1819
1820    #[test]
1821    fn test_remove_path_missing() {
1822        use crate::yaml::Document;
1823        use std::str::FromStr;
1824
1825        let yaml = Document::from_str("name: Alice\n").unwrap();
1826
1827        let result = yaml.try_remove_path("missing").is_ok();
1828        assert!(!result);
1829
1830        let result = yaml.try_remove_path("name.nested").is_ok();
1831        assert!(!result);
1832
1833        // Document should be unchanged
1834        assert_eq!(yaml.to_string(), "name: Alice\n");
1835    }
1836
1837    #[test]
1838    fn test_remove_path_deeply_nested() {
1839        use crate::yaml::Document;
1840        use std::str::FromStr;
1841
1842        let yaml = Document::from_str(
1843            "app:\n  database:\n    primary:\n      host: db.example.com\n      port: 5432\n",
1844        )
1845        .unwrap();
1846
1847        let result = yaml.try_remove_path("app.database.primary.port").is_ok();
1848        assert!(result);
1849
1850        assert_eq!(
1851            yaml.to_string(),
1852            "app:\n  database:\n    primary:\n      host: db.example.com\n"
1853        );
1854    }
1855
1856    #[test]
1857    fn test_remove_path_keeps_sibling_indentation() {
1858        use crate::yaml::Document;
1859        use std::str::FromStr;
1860
1861        // Each entry after the first owns the INDENT token before it.
1862        // Removing an entry without that token left it behind, either
1863        // over-indenting the next entry or dangling as trailing
1864        // whitespace when the removed entry was last.
1865        let doc = Document::from_str("config:\n  a: 1\n  b: 2\n  c: 3\n").unwrap();
1866        doc.try_remove_path("config.b").unwrap();
1867        assert_eq!(doc.to_string(), "config:\n  a: 1\n  c: 3\n");
1868
1869        let doc = Document::from_str("config:\n  a: 1\n  b: 2\n  c: 3\n").unwrap();
1870        doc.try_remove_path("config.a").unwrap();
1871        assert_eq!(doc.to_string(), "config:\n  b: 2\n  c: 3\n");
1872
1873        let doc = Document::from_str("config:\n  a: 1\n  b: 2\n  c: 3\n").unwrap();
1874        doc.try_remove_path("config.c").unwrap();
1875        assert_eq!(doc.to_string(), "config:\n  a: 1\n  b: 2\n");
1876
1877        // Deeper nesting used to compound the over-indentation.
1878        let doc = Document::from_str("x:\n  y:\n    a: 1\n    b: 2\n").unwrap();
1879        doc.try_remove_path("x.y.a").unwrap();
1880        assert_eq!(doc.to_string(), "x:\n  y:\n    b: 2\n");
1881    }
1882
1883    #[test]
1884    fn test_path_on_mapping_directly() {
1885        use crate::yaml::Document;
1886        use std::str::FromStr;
1887
1888        let yaml = Document::from_str("server:\n  host: localhost\n").unwrap();
1889        let mapping = yaml.as_mapping().unwrap();
1890
1891        // Get from mapping
1892        let host = mapping.try_get_path("server.host").ok();
1893        assert_eq!(
1894            host.as_ref()
1895                .and_then(|v| v.as_scalar())
1896                .map(|s| s.to_string()),
1897            Some("localhost".to_string())
1898        );
1899
1900        // Set on mapping
1901        mapping.try_set_path("server.port", 8080).expect("set_path");
1902        assert_eq!(
1903            yaml.to_string(),
1904            "server:\n  host: localhost\n  port: 8080\n"
1905        );
1906
1907        // Remove from mapping
1908        let result = mapping.try_remove_path("server.port").is_ok();
1909        assert!(result);
1910
1911        // Try to remove non-existent path from mapping
1912        let result_missing = mapping.try_remove_path("nonexistent.path").is_ok();
1913        assert!(!result_missing);
1914    }
1915
1916    #[test]
1917    fn test_mapping_remove_path_single_segment() {
1918        use crate::yaml::Document;
1919        use std::str::FromStr;
1920
1921        let yaml = Document::from_str("a: 1\nb: 2\n").unwrap();
1922        let mapping = yaml.as_mapping().unwrap();
1923
1924        // A single-segment path removes the key directly from the mapping.
1925        assert!(mapping.try_remove_path("a").is_ok());
1926        assert!(mapping.try_get_path("a").is_err());
1927        assert!(mapping.try_get_path("b").is_ok());
1928
1929        // Removing a missing single-segment key returns false.
1930        assert!(mapping.try_remove_path("missing").is_err());
1931    }
1932
1933    #[test]
1934    fn test_set_path_preserves_formatting() {
1935        use crate::yaml::Document;
1936        use std::str::FromStr;
1937
1938        let yaml = Document::from_str("server:\n  host: localhost  # production server\n").unwrap();
1939
1940        yaml.try_set_path("server.host", "newhost")
1941            .expect("set_path");
1942
1943        assert_eq!(
1944            yaml.to_string(),
1945            "server:\n  host: newhost  # production server\n"
1946        );
1947    }
1948
1949    #[test]
1950    fn test_multiple_path_operations() {
1951        use crate::yaml::Document;
1952        use std::str::FromStr;
1953
1954        let yaml = Document::from_str("name: test\n").unwrap();
1955
1956        // Create nested structure
1957        yaml.try_set_path("server.host", "localhost")
1958            .expect("set_path");
1959        yaml.try_set_path("server.port", 8080).expect("set_path");
1960        yaml.try_set_path("database.host", "db.local")
1961            .expect("set_path");
1962        yaml.try_set_path("database.port", 5432).expect("set_path");
1963
1964        // Verify all values
1965        assert_eq!(
1966            yaml.try_get_path("server.host")
1967                .ok()
1968                .as_ref()
1969                .and_then(|v| v.as_scalar())
1970                .map(|s| s.to_string()),
1971            Some("localhost".to_string())
1972        );
1973        assert_eq!(
1974            yaml.try_get_path("server.port")
1975                .ok()
1976                .as_ref()
1977                .and_then(|v| v.as_scalar())
1978                .map(|s| s.to_string()),
1979            Some("8080".to_string())
1980        );
1981        assert_eq!(
1982            yaml.try_get_path("database.host")
1983                .ok()
1984                .as_ref()
1985                .and_then(|v| v.as_scalar())
1986                .map(|s| s.to_string()),
1987            Some("db.local".to_string())
1988        );
1989        assert_eq!(
1990            yaml.try_get_path("database.port")
1991                .ok()
1992                .as_ref()
1993                .and_then(|v| v.as_scalar())
1994                .map(|s| s.to_string()),
1995            Some("5432".to_string())
1996        );
1997
1998        // Remove some values
1999        yaml.try_remove_path("server.port").expect("remove_path");
2000        yaml.try_remove_path("database.host").expect("remove_path");
2001
2002        // Verify removals
2003        assert!(yaml.try_get_path("server.port").is_err());
2004        assert!(yaml.try_get_path("database.host").is_err());
2005
2006        // Verify remaining values still exist
2007        assert_eq!(
2008            yaml.try_get_path("server.host")
2009                .ok()
2010                .as_ref()
2011                .and_then(|v| v.as_scalar())
2012                .map(|s| s.to_string()),
2013            Some("localhost".to_string())
2014        );
2015        assert_eq!(
2016            yaml.try_get_path("database.port")
2017                .ok()
2018                .as_ref()
2019                .and_then(|v| v.as_scalar())
2020                .map(|s| s.to_string()),
2021            Some("5432".to_string())
2022        );
2023    }
2024
2025    #[test]
2026    fn test_set_path_creates_intermediate_sequence() {
2027        // Nested collections created inline after `- ` render in
2028        // flow style (`- {c: "value"}`) so re-parse can find the
2029        // key. A block mapping inline after the dash would render
2030        // as a compact-block shape that re-parses ambiguously.
2031        use crate::yaml::Document;
2032        use std::str::FromStr;
2033        let doc = Document::from_str("base: true\n").unwrap();
2034        doc.try_set_path("a.b[0].c", "value").expect("set_path");
2035        assert_eq!(
2036            doc.to_string(),
2037            "base: true\na:\n  b:\n    - {c: \"value\"}\n"
2038        );
2039    }
2040
2041    #[test]
2042    fn test_set_path_into_existing_sequence_by_index() {
2043        use crate::yaml::Document;
2044        use std::str::FromStr;
2045        let doc = Document::from_str("items:\n  - a\n  - b\n").unwrap();
2046        doc.try_set_path("items[1]", "B").expect("set_path");
2047        assert_eq!(doc.to_string(), "items:\n  - a\n  - B\n");
2048    }
2049
2050    #[test]
2051    fn test_set_path_grows_sequence_with_nulls() {
2052        use crate::yaml::Document;
2053        use std::str::FromStr;
2054        let doc = Document::from_str("items:\n  - a\n").unwrap();
2055        doc.try_set_path("items[3]", "z").expect("set_path");
2056        assert_eq!(
2057            doc.to_string(),
2058            "items:\n  - a\n  - null\n  - null\n  - z\n"
2059        );
2060    }
2061
2062    #[test]
2063    fn test_set_path_index_does_not_replace_existing_mapping() {
2064        use crate::yaml::Document;
2065        use std::str::FromStr;
2066        let doc = Document::from_str("m:\n  a: 1\n  b: 2\n").unwrap();
2067        doc.try_set_path("m[0]", "z").expect("set_path");
2068        assert_eq!(doc.to_string(), "m:\n  a: 1\n  b: 2\n  '0': z\n");
2069
2070        let doc = Document::from_str("m:\n  \"0\":\n    x: 1\n").unwrap();
2071        assert_eq!(
2072            doc.try_get_path("m.0.x")
2073                .unwrap()
2074                .as_scalar()
2075                .unwrap()
2076                .as_string(),
2077            "1"
2078        );
2079        doc.try_set_path("m.0.x", "2").expect("set_path");
2080        assert_eq!(
2081            doc.try_get_path("m.0.x")
2082                .unwrap()
2083                .as_scalar()
2084                .unwrap()
2085                .as_string(),
2086            "2"
2087        );
2088        assert!(doc.get_mapping("m").is_some());
2089
2090        let doc = Document::from_str("items:\n  - a: 1\n    b: 2\n").unwrap();
2091        doc.try_set_path("items[0][0]", "z").expect("set_path");
2092        assert_eq!(doc.to_string(), "items:\n  - a: 1\n    b: 2\n    '0': z\n");
2093    }
2094
2095    #[test]
2096    fn test_set_path_key_does_not_replace_existing_sequence() {
2097        use crate::yaml::Document;
2098        use std::str::FromStr;
2099        let original = "items:\n- a\n- b\n";
2100        let doc = Document::from_str(original).unwrap();
2101        assert!(matches!(
2102            doc.try_get_path("items.foo"),
2103            Err(PathError::TypeMismatch { .. })
2104        ));
2105        assert!(matches!(
2106            doc.try_set_path("items.foo", "x"),
2107            Err(PathError::TypeMismatch { .. })
2108        ));
2109        assert_eq!(doc.to_string(), original);
2110
2111        // An alias is refused rather than silently overwritten; the
2112        // dedicated error says why (see the alias policy tests below).
2113        let original = "other: &o\n  a: 1\nitems: *o\n";
2114        let doc = Document::from_str(original).unwrap();
2115        assert_eq!(
2116            doc.try_set_path("items.foo", "x"),
2117            Err(PathError::AliasRefused {
2118                at: "items".to_string(),
2119                alias: "o".to_string()
2120            })
2121        );
2122        assert_eq!(doc.to_string(), original);
2123    }
2124
2125    #[test]
2126    fn test_set_path_index_does_not_replace_alias() {
2127        use crate::yaml::Document;
2128        use std::str::FromStr;
2129
2130        let original = "other: &o\n- a\n- b\nitems: *o\n";
2131        let doc = Document::from_str(original).unwrap();
2132        assert_eq!(
2133            doc.try_get_path("items[0]").unwrap().to_string(),
2134            "a".to_string()
2135        );
2136        assert_eq!(
2137            doc.try_set_path("items[0]", "x"),
2138            Err(PathError::AliasRefused {
2139                at: "items".to_string(),
2140                alias: "o".to_string()
2141            })
2142        );
2143        assert_eq!(doc.to_string(), original);
2144
2145        // A tagged sequence is a sequence wearing a tag: descend into it
2146        // and leave the tag alone (#88).
2147        let doc = Document::from_str("items: !!seq [1, 2]\n").unwrap();
2148        doc.try_set_path("items[1]", "x").unwrap();
2149        assert_eq!(doc.to_string(), "items: !!seq [1, x]\n");
2150
2151        let original = "other: &o\n  a: 1\ntop:\n  - *o\n";
2152        let doc = Document::from_str(original).unwrap();
2153        assert_eq!(
2154            doc.try_set_path("top[0][0]", "x"),
2155            Err(PathError::AliasRefused {
2156                at: "[0]".to_string(),
2157                alias: "o".to_string()
2158            })
2159        );
2160        assert_eq!(doc.to_string(), original);
2161
2162        // A tagged mapping behaves like the mapping it wraps, so the
2163        // numeric-on-mapping fallback applies and `[0]` addresses key "0".
2164        let doc = Document::from_str("top:\n  - !custom\n    a: 1\n").unwrap();
2165        doc.try_set_path("top[0][0]", "x").unwrap();
2166        assert_eq!(doc.to_string(), "top:\n  - !custom\n    a: 1\n    '0': x\n");
2167    }
2168
2169    #[test]
2170    fn test_path_descends_into_tagged_mapping() {
2171        use crate::yaml::Document;
2172        use std::str::FromStr;
2173
2174        let doc = Document::from_str("config: !custom\n  a: 1\n  b: 2\n").unwrap();
2175        assert_eq!(
2176            doc.try_get_path("config.a")
2177                .unwrap()
2178                .as_scalar()
2179                .unwrap()
2180                .as_string(),
2181            "1"
2182        );
2183
2184        doc.try_set_path("config.a", 5).unwrap();
2185        doc.try_set_path("config.c", 7).unwrap();
2186        assert_eq!(doc.to_string(), "config: !custom\n  a: 5\n  b: 2\n  c: 7\n");
2187    }
2188
2189    #[test]
2190    fn test_path_descends_into_tagged_sequence() {
2191        use crate::yaml::Document;
2192        use std::str::FromStr;
2193
2194        let doc = Document::from_str("items: !!seq [1, 2]\n").unwrap();
2195        assert_eq!(
2196            doc.try_get_path("items[0]")
2197                .unwrap()
2198                .as_scalar()
2199                .unwrap()
2200                .as_string(),
2201            "1"
2202        );
2203
2204        doc.try_set_path("items[0]", 9).unwrap();
2205        doc.try_set_path("items[2]", 7).unwrap();
2206        assert_eq!(doc.to_string(), "items: !!seq [9, 2, 7]\n");
2207    }
2208
2209    #[test]
2210    fn test_path_descends_through_nested_tags() {
2211        use crate::yaml::Document;
2212        use std::str::FromStr;
2213
2214        let doc = Document::from_str("a: !outer\n  b: !inner\n    c: 1\n").unwrap();
2215        doc.try_set_path("a.b.c", 2).unwrap();
2216        assert_eq!(doc.to_string(), "a: !outer\n  b: !inner\n    c: 2\n");
2217
2218        let doc = Document::from_str("l: !t\n  - x: 1\n").unwrap();
2219        doc.try_set_path("l[0].x", 9).unwrap();
2220        assert_eq!(doc.to_string(), "l: !t\n  - x: 9\n");
2221    }
2222
2223    #[test]
2224    fn test_remove_path_in_tagged_mapping_keeps_tag() {
2225        use crate::yaml::Document;
2226        use std::str::FromStr;
2227
2228        let doc = Document::from_str("config: !custom\n  a: 1\n  b: 2\n").unwrap();
2229        let removed = doc.try_remove_path("config.b").unwrap();
2230        assert_eq!(removed.as_scalar().unwrap().as_string(), "2");
2231        assert_eq!(doc.to_string(), "config: !custom\n  a: 1\n");
2232
2233        // Draining the last entry collapses to the flow-empty form, as it
2234        // does for an untagged mapping, but keeps the tag.
2235        doc.try_remove_path("config.a").unwrap();
2236        assert_eq!(doc.to_string(), "config: !custom {}\n");
2237    }
2238
2239    #[test]
2240    fn test_path_does_not_descend_into_tagged_scalar() {
2241        use crate::yaml::Document;
2242        use std::str::FromStr;
2243
2244        let original = "config: !custom hello\n";
2245        let doc = Document::from_str(original).unwrap();
2246        assert!(matches!(
2247            doc.try_get_path("config.a"),
2248            Err(PathError::TypeMismatch { .. })
2249        ));
2250        assert!(matches!(
2251            doc.try_set_path("config.a", 1),
2252            Err(PathError::TypeMismatch { .. })
2253        ));
2254        assert_eq!(doc.to_string(), original);
2255    }
2256
2257    const ALIASED: &str = "shared: &shared\n  timeout: 30\nservice: *shared\n";
2258
2259    #[test]
2260    fn test_get_path_resolves_alias_by_default() {
2261        use crate::yaml::Document;
2262        use std::str::FromStr;
2263
2264        let doc = Document::from_str(ALIASED).unwrap();
2265        assert_eq!(
2266            doc.try_get_path("service.timeout").unwrap().to_string(),
2267            "30".to_string()
2268        );
2269        assert_eq!(doc.to_string(), ALIASED.to_string());
2270    }
2271
2272    #[test]
2273    fn test_get_path_refuse_policy_reports_alias() {
2274        use crate::yaml::Document;
2275        use std::str::FromStr;
2276
2277        let doc = Document::from_str(ALIASED).unwrap();
2278        assert_eq!(
2279            doc.try_get_path_with("service.timeout", AliasPolicy::Refuse),
2280            Err(PathError::AliasRefused {
2281                at: "timeout".to_string(),
2282                alias: "shared".to_string()
2283            })
2284        );
2285    }
2286
2287    #[test]
2288    fn test_get_path_undefined_alias() {
2289        use crate::yaml::Document;
2290        use std::str::FromStr;
2291
2292        let doc = Document::from_str(
2293            "service: *missing
2294",
2295        )
2296        .unwrap();
2297        assert_eq!(
2298            doc.try_get_path("service.timeout"),
2299            Err(PathError::UndefinedAlias {
2300                at: "timeout".to_string(),
2301                alias: "missing".to_string()
2302            })
2303        );
2304    }
2305
2306    #[test]
2307    fn test_set_path_refuses_alias_by_default() {
2308        use crate::yaml::Document;
2309        use std::str::FromStr;
2310
2311        let doc = Document::from_str(ALIASED).unwrap();
2312        assert_eq!(
2313            doc.try_set_path("service.timeout", 60),
2314            Err(PathError::AliasRefused {
2315                at: "service".to_string(),
2316                alias: "shared".to_string()
2317            })
2318        );
2319        assert_eq!(doc.to_string(), ALIASED.to_string());
2320    }
2321
2322    #[test]
2323    fn test_set_path_follow_writes_through_to_anchor() {
2324        use crate::yaml::Document;
2325        use std::str::FromStr;
2326
2327        let doc = Document::from_str(ALIASED).unwrap();
2328        doc.try_set_path_with("service.timeout", 60, AliasPolicy::Follow)
2329            .unwrap();
2330        // The anchored value changed, so every alias to it sees 60.
2331        assert_eq!(
2332            doc.to_string(),
2333            "shared: &shared\n  timeout: 60\nservice: *shared\n".to_string()
2334        );
2335    }
2336
2337    #[test]
2338    fn test_set_path_expand_copies_without_anchor() {
2339        use crate::yaml::Document;
2340        use std::str::FromStr;
2341
2342        let doc = Document::from_str(ALIASED).unwrap();
2343        doc.try_set_path_with("service.timeout", 60, AliasPolicy::Expand)
2344            .unwrap();
2345        // The alias is replaced by an independent copy; the anchor keeps
2346        // its original value and carries no duplicate `&shared`.
2347        assert_eq!(
2348            doc.to_string(),
2349            "shared: &shared\n  timeout: 30\nservice:\n  timeout: 60\n".to_string()
2350        );
2351    }
2352
2353    #[test]
2354    fn test_set_path_expand_leaves_other_aliases_alone() {
2355        use crate::yaml::Document;
2356        use std::str::FromStr;
2357
2358        let src = "shared: &shared\n  timeout: 30\na: *shared\nb: *shared\n";
2359        let doc = Document::from_str(src).unwrap();
2360        doc.try_set_path_with("a.timeout", 60, AliasPolicy::Expand)
2361            .unwrap();
2362        assert_eq!(
2363            doc.to_string(),
2364            "shared: &shared\n  timeout: 30\na:\n  timeout: 60\nb: *shared\n".to_string()
2365        );
2366    }
2367
2368    #[test]
2369    fn test_set_path_replacing_alias_outright_needs_no_policy() {
2370        use crate::yaml::Document;
2371        use std::str::FromStr;
2372
2373        // A single-segment path overwrites the alias entry itself rather
2374        // than writing through it, so the default policy allows it.
2375        let doc = Document::from_str(ALIASED).unwrap();
2376        doc.try_set_path("service", 1).unwrap();
2377        assert_eq!(
2378            doc.to_string(),
2379            "shared: &shared\n  timeout: 30\nservice: 1\n".to_string()
2380        );
2381    }
2382
2383    #[test]
2384    fn test_set_path_undefined_alias_is_reported() {
2385        use crate::yaml::Document;
2386        use std::str::FromStr;
2387
2388        let src = "service: *missing\n";
2389        let doc = Document::from_str(src).unwrap();
2390        assert_eq!(
2391            doc.try_set_path_with("service.timeout", 1, AliasPolicy::Follow),
2392            Err(PathError::UndefinedAlias {
2393                at: "service".to_string(),
2394                alias: "missing".to_string()
2395            })
2396        );
2397        assert_eq!(doc.to_string(), src.to_string());
2398    }
2399
2400    #[test]
2401    fn test_remove_path_refuses_alias_by_default() {
2402        use crate::yaml::Document;
2403        use std::str::FromStr;
2404
2405        let doc = Document::from_str(ALIASED).unwrap();
2406        assert_eq!(
2407            doc.try_remove_path("service.timeout"),
2408            Err(PathError::AliasRefused {
2409                at: "timeout".to_string(),
2410                alias: "shared".to_string()
2411            })
2412        );
2413        assert_eq!(doc.to_string(), ALIASED.to_string());
2414    }
2415
2416    #[test]
2417    fn test_remove_path_follow_removes_from_anchor() {
2418        use crate::yaml::Document;
2419        use std::str::FromStr;
2420
2421        let src = "shared: &shared\n  timeout: 30\n  retries: 3\nservice: *shared\n";
2422        let doc = Document::from_str(src).unwrap();
2423        let removed = doc
2424            .try_remove_path_with("service.timeout", AliasPolicy::Follow)
2425            .unwrap();
2426        assert_eq!(removed.to_string(), "30".to_string());
2427        // The key is gone from the anchored mapping, so the alias sees
2428        // the removal too.
2429        assert_eq!(
2430            doc.to_string(),
2431            "shared: &shared\n  retries: 3\nservice: *shared\n".to_string()
2432        );
2433    }
2434
2435    #[test]
2436    fn test_alias_to_sequence_resolves_for_read() {
2437        use crate::yaml::Document;
2438        use std::str::FromStr;
2439
2440        let doc = Document::from_str("other: &o\n- a\n- b\nitems: *o\n").unwrap();
2441        assert_eq!(
2442            doc.try_get_path("items[1]").unwrap().to_string(),
2443            "b".to_string()
2444        );
2445    }
2446
2447    #[test]
2448    fn test_get_path_on_mapping_resolves_document_anchors() {
2449        use crate::yaml::Document;
2450        use std::str::FromStr;
2451
2452        // Traversal started from a nested Mapping, but the anchor is
2453        // defined at the document root: the registry is built from the
2454        // whole tree, so it still resolves.
2455        let doc =
2456            Document::from_str("shared: &shared\n  timeout: 30\nouter:\n  svc: *shared\n").unwrap();
2457        let outer = doc.as_mapping().unwrap().get_mapping("outer").unwrap();
2458        assert_eq!(
2459            outer.try_get_path("svc.timeout").unwrap().to_string(),
2460            "30".to_string()
2461        );
2462    }
2463
2464    #[test]
2465    fn test_recursive_alias_terminates() {
2466        use crate::yaml::Document;
2467        use std::str::FromStr;
2468
2469        // Each segment resolves exactly one alias hop, so a self
2470        // referential anchor consumes path segments instead of looping.
2471        let doc = Document::from_str("a: &x\n  b: *x\n").unwrap();
2472        assert!(doc.try_get_path("a.b.b.b").is_ok());
2473    }
2474}