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}