Skip to main content

noyalib/cst/
entry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Path-shaped mutable handle to a CST node — the `Entry` "pro"
5//! interface that complements the functional `Document::set` /
6//! `remove` / `push_back` / `insert_after` methods.
7//!
8//! The functional methods stay first-class: when the caller knows
9//! the operation up front, `doc.set("server.port", "9090")?` is the
10//! shortest path. `Entry` exists for the *idiomatic* / *composable*
11//! cases:
12//!
13//! - chaining nested mutations without re-typing the prefix
14//!   (`doc.entry("server").set_value(...)?`),
15//! - reading-then-writing a field by the same handle
16//!   (`if entry.exists() { entry.set("...")?; }`),
17//! - inserting into a child mapping by name without manual
18//!   path-string concatenation
19//!   (`doc.entry("metadata.labels").insert("env", "prod")?`),
20//! - keeping a single mutable borrow across a sequence of edits.
21//!
22//! Every operation routes through the same lossless CST splice path
23//! the functional methods use — comments, blank lines, sibling
24//! entries, and indentation are preserved byte-for-byte across
25//! every Entry-driven edit.
26
27use crate::cst::Document;
28use crate::error::{Error, Result};
29use crate::prelude::*;
30use crate::value::Value;
31
32/// A path-shaped mutable handle to a node in a [`Document`].
33///
34/// Created by [`Document::entry`]. Every method routes through the
35/// same CST splice the functional API uses, so edits are byte-faithful
36/// outside the touched span.
37///
38/// # Examples
39///
40/// Single-level edit, equivalent to `Document::set`:
41///
42/// ```
43/// use noyalib::cst::parse_document;
44///
45/// let mut doc = parse_document("port: 8080\n").unwrap();
46/// doc.entry("port").set("9090").unwrap();
47/// assert_eq!(doc.to_string(), "port: 9090\n");
48/// ```
49///
50/// Nested edit by chained drill-down — no string concatenation:
51///
52/// ```
53/// use noyalib::cst::parse_document;
54///
55/// let mut doc = parse_document(
56///     "server:\n  host: localhost\n  port: 8080\n",
57/// ).unwrap();
58/// doc.entry("server").entry("port").set("9090").unwrap();
59/// assert!(doc.to_string().contains("port: 9090"));
60/// ```
61///
62/// Insert into a mapping by key without manual path strings:
63///
64/// ```
65/// use noyalib::cst::parse_document;
66///
67/// let mut doc = parse_document(
68///     "metadata:\n  labels:\n    app: noyalib\n",
69/// ).unwrap();
70/// doc.entry("metadata.labels").insert("env", "prod").unwrap();
71/// let out = doc.to_string();
72/// assert!(out.contains("app: noyalib"));
73/// assert!(out.contains("env: prod"));
74/// ```
75#[derive(Debug)]
76pub struct Entry<'a> {
77    doc: &'a mut Document,
78    path: String,
79}
80
81impl<'a> Entry<'a> {
82    pub(crate) fn new(doc: &'a mut Document, path: String) -> Self {
83        Self { doc, path }
84    }
85
86    /// The path this entry represents, as a dotted/indexed string.
87    #[must_use]
88    pub fn path(&self) -> &str {
89        &self.path
90    }
91
92    /// `true` if a node currently exists at this path.
93    #[must_use]
94    pub fn exists(&self) -> bool {
95        self.doc.span_at(&self.path).is_some()
96    }
97
98    /// The current source slice at this path, or `None` if the path
99    /// does not resolve. Forwards to [`Document::get`].
100    #[must_use]
101    pub fn get(&self) -> Option<&str> {
102        self.doc.get(&self.path)
103    }
104
105    /// The byte range `(start, end)` at this path, or `None` if the
106    /// path does not resolve. Forwards to [`Document::span_at`].
107    #[must_use]
108    pub fn span_at(&self) -> Option<(usize, usize)> {
109        self.doc.span_at(&self.path)
110    }
111
112    /// Comments that decorate this node. Forwards to
113    /// [`Document::comments_at`].
114    #[must_use]
115    pub fn comments(&self) -> crate::cst::CommentBundle {
116        self.doc.comments_at(&self.path)
117    }
118
119    /// Replace the value at this path with `fragment`. Forwards to
120    /// [`Document::set`].
121    ///
122    /// Consumes the entry. Returns the [`Document`]'s reference back
123    /// via the borrow expiring; chain a fresh `doc.entry(...)` for
124    /// the next edit.
125    ///
126    /// # Errors
127    ///
128    /// Same as [`Document::set`] — invalid path, parse-after-splice
129    /// failure, etc.
130    pub fn set(self, fragment: &str) -> Result<()> {
131        self.doc.set(&self.path, fragment)
132    }
133
134    /// Replace the value at this path with the YAML emission of
135    /// `value`. Forwards to [`Document::set_value`].
136    ///
137    /// # Errors
138    ///
139    /// Same as [`Document::set_value`].
140    pub fn set_value(self, value: &Value) -> Result<()> {
141        self.doc.set_value(&self.path, value)
142    }
143
144    /// Remove the entry at this path. Forwards to
145    /// [`Document::remove`].
146    ///
147    /// # Errors
148    ///
149    /// Same as [`Document::remove`].
150    pub fn remove(self) -> Result<()> {
151        self.doc.remove(&self.path)
152    }
153
154    /// Insert a `key: value` pair into the mapping at this path.
155    /// If the key already exists, the value is replaced losslessly;
156    /// if it is new, a sibling line is spliced after the last
157    /// existing entry with matching indent. Forwards to
158    /// [`Document::insert_entry`].
159    ///
160    /// # Errors
161    ///
162    /// - The mapping at `self.path` does not exist.
163    /// - The mapping is empty (no anchor for indent — use
164    ///   [`Self::set`] with a fragment to give the mapping its first
165    ///   entry).
166    /// - The same parse-after-splice errors as the underlying
167    ///   `replace_span`.
168    pub fn insert(self, key: &str, fragment: &str) -> Result<()> {
169        self.doc.insert_entry(&self.path, key, fragment)
170    }
171
172    /// Insert a `key: value` pair where the value is a typed
173    /// [`Value`], auto-formatting both halves.
174    ///
175    /// Forwards to [`Document::insert_entry_value`]: the value is
176    /// emitted so it re-parses to exactly `value` — quoted when the
177    /// plain spelling would not — the key is quoted when it needs to
178    /// be, indentation follows the file's detected unit, and the
179    /// splice is held to a re-parse plus typed-value check that rolls
180    /// back on any mismatch.
181    ///
182    /// # Errors
183    ///
184    /// As [`Document::insert_entry_value`].
185    pub fn insert_value(self, key: &str, value: &Value) -> Result<()> {
186        self.doc.insert_entry_value(&self.path, key, value)
187    }
188
189    /// Append a typed [`Value`] to the sequence at this path.
190    /// Forwards to [`Document::push_back_value`].
191    ///
192    /// # Errors
193    ///
194    /// As [`Document::push_back_value`] — the path must point to a
195    /// non-empty block sequence.
196    pub fn push_back_value(self, value: &Value) -> Result<()> {
197        self.doc.push_back_value(&self.path, value)
198    }
199
200    /// Insert a typed [`Value`] as a new sequence item immediately
201    /// after the item at this path. Forwards to
202    /// [`Document::insert_after_value`].
203    ///
204    /// # Errors
205    ///
206    /// As [`Document::insert_after_value`] — the path must end in an
207    /// index and resolve to an item of a block sequence.
208    pub fn insert_after_value(self, value: &Value) -> Result<()> {
209        self.doc.insert_after_value(&self.path, value)
210    }
211
212    /// Append `fragment` as a new item to the sequence at this path.
213    /// Forwards to [`Document::push_back`].
214    ///
215    /// # Errors
216    ///
217    /// Same as [`Document::push_back`] — the path must point to a
218    /// non-empty block sequence.
219    pub fn push_back(self, fragment: &str) -> Result<()> {
220        self.doc.push_back(&self.path, fragment)
221    }
222
223    /// Insert `fragment` as a new sequence item immediately after
224    /// the item at this path. Forwards to [`Document::insert_after`].
225    ///
226    /// # Errors
227    ///
228    /// Same as [`Document::insert_after`] — the path must end in an
229    /// index and resolve to a sequence item in a block sequence.
230    pub fn insert_after(self, fragment: &str) -> Result<()> {
231        self.doc.insert_after(&self.path, fragment)
232    }
233
234    /// Drill down to a child path. The returned [`Entry`] represents
235    /// `self.path + "." + child`, holding the same mutable borrow. A
236    /// child that begins with `[` — an index, or the quoted segment
237    /// [`crate::path::quote_key`] spells for a key holding `.`, `[`,
238    /// `]`, or `*` — is appended without the separator.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// use noyalib::cst::parse_document;
244    ///
245    /// let mut doc = parse_document(
246    ///     "a:\n  b:\n    c: 1\n",
247    /// ).unwrap();
248    /// doc.entry("a").entry("b").entry("c").set("2").unwrap();
249    /// assert!(doc.to_string().contains("c: 2"));
250    /// ```
251    #[must_use]
252    pub fn entry(self, child: &str) -> Self {
253        let combined = compose_path(&self.path, child);
254        Entry::new(self.doc, combined)
255    }
256
257    /// Ensure a value exists at this path: if the entry already
258    /// resolves, leave it untouched; if it does not, splice
259    /// `default` as the new value.
260    ///
261    /// Mirrors the [`std::collections::hash_map::Entry::or_insert`]
262    /// pattern adapted to the CST: the path is *resolved through the
263    /// green tree first*, then the splice runs only on the vacant
264    /// branch. The resolution is byte-faithful and skips the typed
265    /// `Value` cache, so the call is cheap on already-occupied paths.
266    ///
267    /// Returns `Ok(true)` when the splice ran (the entry was
268    /// vacant), `Ok(false)` when the entry was already occupied
269    /// (no-op).
270    ///
271    /// # Errors
272    ///
273    /// - For mapping inserts: as [`Self::insert`] — the parent
274    ///   mapping must exist at the dotted-path prefix and must not
275    ///   be empty.
276    /// - For top-level inserts (no `.` in path): rejected with a
277    ///   clear message; use [`Document::set`] with a fresh document
278    ///   for that case.
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use noyalib::cst::parse_document;
284    ///
285    /// let mut doc = parse_document(
286    ///     "metadata:\n  labels:\n    app: noyalib\n",
287    /// ).unwrap();
288    ///
289    /// // First call: the path was vacant, so the splice ran.
290    /// let inserted = doc.entry("metadata.labels.env")
291    ///     .or_insert("prod")
292    ///     .unwrap();
293    /// assert!(inserted);
294    /// assert!(doc.to_string().contains("env: prod"));
295    ///
296    /// // Second call on the same path: the entry is now occupied,
297    /// // so it returns `false` and leaves the value untouched.
298    /// let inserted = doc.entry("metadata.labels.env")
299    ///     .or_insert("staging")
300    ///     .unwrap();
301    /// assert!(!inserted);
302    /// assert!(doc.to_string().contains("env: prod"));
303    /// ```
304    pub fn or_insert(self, default: &str) -> Result<bool> {
305        if self.exists() {
306            return Ok(false);
307        }
308        self.insert_at_path(default)?;
309        Ok(true)
310    }
311
312    /// Like [`Self::or_insert`] but constructs the default lazily —
313    /// the closure runs only on the vacant branch.
314    ///
315    /// # Errors
316    ///
317    /// As [`Self::or_insert`].
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use noyalib::cst::parse_document;
323    ///
324    /// let mut doc = parse_document(
325    ///     "metadata:\n  labels:\n    app: noyalib\n",
326    /// ).unwrap();
327    /// let _ = doc.entry("metadata.labels.env")
328    ///     .or_insert_with(|| "prod".to_owned())
329    ///     .unwrap();
330    /// assert!(doc.to_string().contains("env: prod"));
331    /// ```
332    pub fn or_insert_with<F>(self, default: F) -> Result<bool>
333    where
334        F: FnOnce() -> String,
335    {
336        if self.exists() {
337            return Ok(false);
338        }
339        let frag = default();
340        self.insert_at_path(&frag)?;
341        Ok(true)
342    }
343
344    /// Like [`Self::or_insert`] but takes a typed [`Value`]. Honours
345    /// the file's detected indent unit
346    /// ([`Document::indent_unit`]) when emitting the value.
347    ///
348    /// # Errors
349    ///
350    /// As [`Self::or_insert`], plus serializer errors when the value
351    /// cannot be emitted as YAML.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use noyalib::cst::parse_document;
357    /// use noyalib::Value;
358    ///
359    /// let mut doc = parse_document(
360    ///     "metadata:\n  labels:\n    app: noyalib\n",
361    /// ).unwrap();
362    /// let _ = doc.entry("metadata.labels.replicas")
363    ///     .or_insert_value(&Value::Number(noyalib::Number::Integer(3)))
364    ///     .unwrap();
365    /// assert!(doc.to_string().contains("replicas: 3"));
366    /// ```
367    pub fn or_insert_value(self, default: &Value) -> Result<bool> {
368        if self.exists() {
369            return Ok(false);
370        }
371        // Splits the leaf key off the path and goes through the
372        // auto-formatting insert, so the value is quoted to re-parse
373        // as itself and the splice is guarded — the same contract
374        // `insert_value` offers.
375        self.insert_value_at_path(default)?;
376        Ok(true)
377    }
378
379    /// If the entry is occupied, run `f` and let it apply
380    /// arbitrary mutations through a fresh sub-borrow of the
381    /// document. No-op when the entry is vacant. The closure
382    /// receives a [`Document`] reference so it can touch any path,
383    /// not just `self.path` — useful for "if this exists, also
384    /// update its sibling" patterns.
385    ///
386    /// Returns `self` for further chaining (typically with
387    /// [`Self::or_insert`]) — mirrors
388    /// [`std::collections::hash_map::Entry::and_modify`].
389    ///
390    /// # Examples
391    ///
392    /// ```
393    /// use noyalib::cst::parse_document;
394    ///
395    /// let mut doc = parse_document(
396    ///     "service:\n  port: 8080\n",
397    /// ).unwrap();
398    ///
399    /// // Increment-style update: read, mutate, write — all under
400    /// // the same Entry handle. The closure only runs because the
401    /// // path is occupied.
402    /// let _ = doc.entry("service.port")
403    ///     .and_modify(|d| {
404    ///         let _ = d.set("service.port", "9090");
405    ///     })
406    ///     .or_insert("8080") // no-op now: path exists
407    ///     .unwrap();
408    /// assert!(doc.to_string().contains("port: 9090"));
409    /// ```
410    pub fn and_modify<F>(self, f: F) -> Self
411    where
412        F: FnOnce(&mut Document),
413    {
414        if self.doc.span_at(&self.path).is_some() {
415            f(self.doc);
416        }
417        self
418    }
419
420    /// Helper: split `self.path` into `(parent, leaf)` and call
421    /// `Document::insert_entry`. Top-level paths (no `.`) and
422    /// sequence-index paths are rejected with an actionable error.
423    fn insert_at_path(self, fragment: &str) -> Result<()> {
424        let path = self.path;
425        // Sequence indexes can't be inserted via the mapping path —
426        // detect either a leading `[` or a final `[index]` segment
427        // anywhere in the dotted path so the error is actionable.
428        if path.contains('[') {
429            return Err(Error::Parse(format!(
430                "or_insert: cannot insert at sequence index `{path}`; \
431                 use Entry::push_back or Entry::insert_after instead"
432            )));
433        }
434        match path.rfind('.') {
435            Some(idx) => {
436                let (parent, key_with_dot) = path.split_at(idx);
437                // Skip the leading `.` to get the leaf key.
438                let key = &key_with_dot[1..];
439                self.doc.insert_entry(parent, key, fragment)
440            }
441            None => Err(Error::Parse(format!(
442                "or_insert: cannot insert at top-level key `{path}` \
443                 on an existing document — use Document::set on a \
444                 non-existent path or insert through a parent mapping"
445            ))),
446        }
447    }
448
449    /// The typed analogue of [`Self::insert_at_path`]: split the leaf
450    /// key off `self.path` and insert `value` under it with
451    /// auto-formatting and the post-splice guard.
452    fn insert_value_at_path(self, value: &Value) -> Result<()> {
453        let path = self.path;
454        if path.contains('[') {
455            return Err(Error::Parse(format!(
456                "or_insert: cannot insert at sequence index `{path}`; \
457                 use Entry::push_back_value or Entry::insert_after_value instead"
458            )));
459        }
460        match path.rfind('.') {
461            Some(idx) => {
462                let (parent, key_with_dot) = path.split_at(idx);
463                let key = &key_with_dot[1..];
464                self.doc.insert_entry_value(parent, key, value)
465            }
466            None => Err(Error::Parse(format!(
467                "or_insert: cannot insert at top-level key `{path}` \
468                 on an existing document — use Document::set on a \
469                 non-existent path or insert through a parent mapping"
470            ))),
471        }
472    }
473}
474
475impl Document {
476    /// Return a path-shaped mutable handle to the node at `path`.
477    ///
478    /// The handle is the "pro" mutation interface — chainable,
479    /// composable, ergonomic for nested edits — that complements the
480    /// functional [`Document::set`] / [`Document::remove`] /
481    /// [`Document::push_back`] / [`Document::insert_after`] methods
482    /// (all of which remain available for direct one-shot operations).
483    ///
484    /// `entry` itself is infallible — the path is recorded but not
485    /// resolved at this point. Operations on the returned entry
486    /// surface path-resolution and splice-failure errors via their
487    /// own `Result`.
488    ///
489    /// # Examples
490    ///
491    /// ```
492    /// use noyalib::cst::parse_document;
493    ///
494    /// let mut doc = parse_document(
495    ///     "metadata:\n  labels:\n    app: noyalib\n",
496    /// ).unwrap();
497    /// doc.entry("metadata.labels").insert("env", "prod").unwrap();
498    ///
499    /// let out = doc.to_string();
500    /// assert!(out.contains("app: noyalib"));
501    /// assert!(out.contains("env: prod"));
502    /// ```
503    pub fn entry<'a>(&'a mut self, path: &str) -> Entry<'a> {
504        Entry::new(self, path.to_owned())
505    }
506}
507
508/// Concatenate a parent path and a child segment with a dot
509/// separator, except when the parent is empty (in which case the
510/// child is the whole path) or the child already begins with `[`
511/// (an index — no separator needed).
512fn compose_path(parent: &str, child: &str) -> String {
513    if parent.is_empty() {
514        return child.to_owned();
515    }
516    if child.starts_with('[') {
517        return format!("{parent}{child}");
518    }
519    format!("{parent}.{child}")
520}
521
522// ── Trait impls ──────────────────────────────────────────────────────
523
524impl Error {
525    /// Internal — surface a generic "no entry" message used by the
526    /// upcoming `or_insert` machinery (v0.0.2). Defined here so the
527    /// public Error API does not grow a new variant for the in-flight
528    /// work yet.
529    #[doc(hidden)]
530    #[must_use]
531    pub fn entry_not_found(path: &str) -> Self {
532        Self::Parse(format!("entry not found at path: {path}"))
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::cst::parse_document;
540
541    #[test]
542    fn entry_set_replaces_value_losslessly() {
543        let mut doc = parse_document("# top comment\nport: 8080  # inline comment\n").unwrap();
544        doc.entry("port").set("9090").unwrap();
545        let out = doc.to_string();
546        assert!(out.contains("port: 9090"));
547        assert!(out.contains("# top comment"));
548        assert!(out.contains("# inline comment"));
549    }
550
551    #[test]
552    fn entry_chains_through_dotted_path() {
553        let mut doc = parse_document("server:\n  host: localhost\n  port: 8080\n").unwrap();
554        doc.entry("server").entry("port").set("9090").unwrap();
555        assert!(doc.to_string().contains("port: 9090"));
556    }
557
558    #[test]
559    fn entry_insert_into_mapping() {
560        let mut doc = parse_document("metadata:\n  labels:\n    app: noyalib\n").unwrap();
561        doc.entry("metadata.labels").insert("env", "prod").unwrap();
562        let out = doc.to_string();
563        assert!(out.contains("app: noyalib"));
564        assert!(out.contains("env: prod"));
565    }
566
567    #[test]
568    fn entry_insert_value_typed() {
569        let mut doc = parse_document("metadata:\n  labels:\n    app: noyalib\n").unwrap();
570        let v = Value::Number(crate::Number::Integer(3));
571        doc.entry("metadata.labels")
572            .insert_value("replicas", &v)
573            .unwrap();
574        let out = doc.to_string();
575        assert!(out.contains("replicas: 3"));
576    }
577
578    #[test]
579    fn entry_remove() {
580        let mut doc = parse_document("a: 1\nb: 2\nc: 3\n").unwrap();
581        doc.entry("b").remove().unwrap();
582        let out = doc.to_string();
583        assert!(out.contains("a: 1"));
584        assert!(!out.contains("b:"));
585        assert!(out.contains("c: 3"));
586    }
587
588    #[test]
589    fn entry_push_back_to_sequence() {
590        let mut doc = parse_document("items:\n  - one\n  - two\n").unwrap();
591        doc.entry("items").push_back("three").unwrap();
592        let out = doc.to_string();
593        assert!(out.contains("- one"));
594        assert!(out.contains("- two"));
595        assert!(out.contains("- three"));
596    }
597
598    #[test]
599    fn entry_insert_after_in_sequence() {
600        let mut doc = parse_document("items:\n  - one\n  - three\n").unwrap();
601        doc.entry("items[0]").insert_after("two").unwrap();
602        let out = doc.to_string();
603        let one_pos = out.find("one").unwrap();
604        let two_pos = out.find("two").unwrap();
605        let three_pos = out.find("three").unwrap();
606        assert!(one_pos < two_pos);
607        assert!(two_pos < three_pos);
608    }
609
610    #[test]
611    fn entry_get_reads_source_slice() {
612        let doc = parse_document("port: 8080\n").unwrap();
613        // `entry` needs `&mut self`, so reads via Entry require a
614        // throwaway doc — but the `get` method is a pure read.
615        let mut doc = doc;
616        let e = doc.entry("port");
617        assert_eq!(e.get(), Some("8080"));
618    }
619
620    #[test]
621    fn entry_exists_distinguishes_present_and_absent() {
622        let mut doc = parse_document("a: 1\n").unwrap();
623        assert!(doc.entry("a").exists());
624        assert!(!doc.entry("b").exists());
625    }
626
627    #[test]
628    fn entry_path_returns_recorded_string() {
629        let mut doc = parse_document("a:\n  b: 1\n").unwrap();
630        let e = doc.entry("a").entry("b");
631        assert_eq!(e.path(), "a.b");
632    }
633
634    #[test]
635    fn entry_with_index_uses_no_dot_separator() {
636        let mut doc = parse_document("items:\n  - one\n  - two\n").unwrap();
637        // `entry("items").entry("[0]")` should produce `items[0]`,
638        // not `items.[0]`.
639        let e = doc.entry("items").entry("[0]");
640        assert_eq!(e.path(), "items[0]");
641    }
642
643    #[test]
644    fn entry_comments_forwards_to_document() {
645        let mut doc = parse_document("# decorator\nport: 8080  # inline\n").unwrap();
646        let bundle = doc.entry("port").comments();
647        assert_eq!(bundle.before.len(), 1);
648        assert_eq!(bundle.before[0].text, " decorator");
649        assert_eq!(bundle.inline.as_ref().unwrap().text, " inline");
650    }
651
652    #[test]
653    fn entry_set_on_nonexistent_path_errors() {
654        let mut doc = parse_document("a: 1\n").unwrap();
655        let err = doc.entry("nonexistent").set("2").unwrap_err();
656        assert!(err.to_string().contains("path not found"));
657    }
658
659    #[test]
660    fn entry_repeated_edits_compose() {
661        // Multiple Entry-driven edits in sequence — each releases
662        // its borrow at the end of its statement, the next can
663        // reborrow doc cleanly.
664        let mut doc = parse_document("name: noyalib\nversion: 0.0.1\n").unwrap();
665        doc.entry("name").set("renamed").unwrap();
666        doc.entry("version").set("0.0.2").unwrap();
667        let out = doc.to_string();
668        assert!(out.contains("name: renamed"));
669        assert!(out.contains("version: 0.0.2"));
670    }
671}