noyalib/de.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! YAML Deserialization.
5//!
6//! # Examples
7//!
8//! ```
9//! use noyalib::from_str;
10//! use std::collections::BTreeMap;
11//! let m: BTreeMap<String, i32> = from_str("a: 1\nb: 2\n").unwrap();
12//! assert_eq!(m.get("a"), Some(&1));
13//! ```
14
15use crate::error::{Error, Result};
16use crate::parser::{self};
17use crate::prelude::*;
18#[cfg(feature = "std")]
19use crate::span_context;
20use crate::value::Value;
21#[cfg(feature = "std")]
22use std::io;
23
24mod config;
25mod deserializer;
26pub use config::{
27 DuplicateKeyPolicy, MergeKeyPolicy, NonScalarKeyPolicy, ParserConfig, ParserLimits,
28 ParserProfile, RequireIndent, YamlVersion,
29};
30pub use deserializer::Deserializer;
31pub(crate) use deserializer::{EmptyMapAccess, SpannedMapAccess, is_binary_tag};
32
33/// Deserialize YAML from a `&str` into a typed `T`.
34///
35/// Default entry point for typed deserialisation. Drives the
36/// streaming fast-path when the input matches its eligibility
37/// rules (no custom merge-key policy, no ignore-binary-tag mode,
38/// no registered policies); otherwise routes through the
39/// `Value`-AST loader. The choice is transparent — both paths
40/// produce identical results.
41///
42/// # Errors
43///
44/// Returns [`Error`](crate::Error) when:
45///
46/// - `Error::Parse` / `Error::ParseWithLocation` — `s` is not
47/// well-formed YAML 1.2 (missing closing bracket, indentation
48/// mismatch, invalid escape, …).
49/// - `Error::Deserialize` — the document parses but does not
50/// match `T`'s shape (wrong scalar type, missing required field
51/// on a struct without `#[serde(default)]`, unknown enum
52/// variant, …). A rejection inside a nested value names the
53/// field it is about — `server.port: invalid type: string "x",
54/// expected u16` — alongside the source location (#353).
55/// - `Error::DepthLimit` / `Error::DocumentTooLong` /
56/// `Error::AliasLimit` — input exceeds the default
57/// [`ParserConfig`] safety budgets. Use
58/// [`from_str_with_config`] with [`ParserConfig::strict()`] for
59/// tighter limits or with relaxed limits if the defaults
60/// reject a known-good document.
61/// - `Error::DuplicateKey` — only when `duplicate_key_policy`
62/// has been switched to `Error`. The default policy is `Last`,
63/// which deduplicates without erroring.
64/// - `Error::Custom` — surface for upstream `serde_core::de::Error`
65/// conversions; ordinarily the more specific variants above are
66/// produced first.
67///
68/// # Examples
69///
70/// ```
71/// let n: i32 = noyalib::from_str("42").unwrap();
72/// assert_eq!(n, 42);
73/// ```
74pub fn from_str<T>(s: &str) -> Result<T>
75where
76 T: for<'de> serde_core::Deserialize<'de> + 'static,
77{
78 from_str_with_config(s, &ParserConfig::default())
79}
80
81/// Deserialise a YAML document into a target type that may borrow
82/// from the input slice (e.g. `&'a str`, `Cow<'a, str>`, structs
83/// containing those).
84///
85/// Where [`from_str`] requires `T: DeserializeOwned + 'static` and
86/// therefore can never satisfy `Deserialize<'de> for &'de str`, this
87/// function pins the deserialiser's lifetime to the input buffer's
88/// lifetime. Plain (unquoted) and unescaped quoted scalars are
89/// surfaced via `visit_borrowed_str`, allowing zero-copy borrows.
90/// Scalars that required transformation (escape decoding, line
91/// folding, alias replay, tag resolution) fall back to owned
92/// allocations — see [`crate::borrowed::TransformReason`] for the
93/// catalogue of transform causes.
94///
95/// # Errors
96///
97/// Returns an error if `s` is not valid YAML, exceeds the default
98/// security limits, or cannot be deserialised into `T`. When `T`
99/// targets `&'a str` and the YAML scalar required transformation,
100/// serde's `&str` visitor surfaces an `invalid type: string,
101/// expected a borrowed string` error — switch the target to
102/// `Cow<'a, str>` or `String` to accept either form.
103///
104/// # Examples
105///
106/// ```
107/// use noyalib::from_str_borrowing;
108///
109/// #[derive(serde::Deserialize)]
110/// struct Person<'a> {
111/// name: &'a str,
112/// role: &'a str,
113/// }
114///
115/// let yaml = "name: noyalib\nrole: parser\n";
116/// let p: Person<'_> = from_str_borrowing(yaml).unwrap();
117/// assert_eq!(p.name, "noyalib");
118/// assert_eq!(p.role, "parser");
119/// ```
120pub fn from_str_borrowing<'de, T>(s: &'de str) -> Result<T>
121where
122 T: serde_core::Deserialize<'de>,
123{
124 from_str_borrowing_with_config(s, &ParserConfig::default())
125}
126
127/// [`from_str_borrowing`] with a custom [`ParserConfig`] — typically
128/// to tighten security limits for untrusted input.
129///
130/// # Errors
131///
132/// Returns an error if `s` is not valid YAML under the supplied
133/// config or cannot be deserialised into `T`.
134///
135/// # Examples
136///
137/// ```
138/// use noyalib::{from_str_borrowing_with_config, ParserConfig};
139/// let cfg = ParserConfig::strict();
140/// let s: &str = from_str_borrowing_with_config("hello\n", &cfg).unwrap();
141/// assert_eq!(s, "hello");
142/// ```
143pub fn from_str_borrowing_with_config<'de, T>(s: &'de str, config: &ParserConfig) -> Result<T>
144where
145 T: serde_core::Deserialize<'de>,
146{
147 let parse_config = parser::ParseConfig::from(config);
148 if s.len() > parse_config.max_document_length {
149 return Err(Error::Parse(format!(
150 "document exceeds maximum length of {} bytes",
151 parse_config.max_document_length
152 )));
153 }
154 // Event policies run inside the streaming deserializer's meter; the
155 // whole-document checks need the document, so build it first when a
156 // policy is registered.
157 if !config.policies.is_empty() {
158 let document = parser::parse_exactly_one_value(s, &parse_config)?;
159 crate::policy::check_document(&config.policies, &document)?;
160 }
161 let mut de = crate::streaming::StreamingDeserializer::with_config(s, parse_config);
162 if let Some(registry) = config.tag_registry.as_ref() {
163 de = de.with_tag_registry(Arc::clone(registry));
164 }
165 let value = T::deserialize(&mut de)?;
166 // Same end-of-stream rule as `from_str`: a second document or a
167 // trailing syntax error is refused, not silently ignored.
168 de.end()?;
169 Ok(value)
170}
171
172/// Compile-time-ish check: is the deserialise target `T` exactly
173/// [`Value`]? Used by [`from_str_with_config`] / [`from_value`]
174/// to enable the tag-preserving fast-path on
175/// [`Deserializer::deserialize_any`] only when the caller wants a
176/// `Value`. For typed targets (`#[derive(serde::Deserialize)] struct`,
177/// scalars, enums, …) the standard transparent-tag behaviour
178/// stays in place.
179///
180/// `Value` is `'static`, so [`std::any::TypeId::of`] is well-formed
181/// here. The check returns `false` for any other `T`, including
182/// `Spanned<Value>` and `Vec<Value>` (where the outer wrapper has
183/// a distinct `TypeId`).
184fn is_value_target<T: 'static + ?Sized>() -> bool {
185 use core::any::TypeId;
186 TypeId::of::<T>() == TypeId::of::<Value>()
187}
188
189/// Internal typed-deserialise entry that does **not** require
190/// `T: 'static` and never engages the tag-preserving fast-path.
191///
192/// Used by integrations whose external trait signatures expose
193/// `T: for<'de> serde_core::Deserialize<'de>` without a `'static` bound (e.g.
194/// the [`figment`] crate's [`figment::Format::from_str`]
195/// signature). In those contexts the caller has already
196/// type-erased through `T = ProfileFigure` etc., and a tag-
197/// preserving Value reconstitution would never apply anyway.
198///
199/// Mirrors [`from_str_with_config`] in every other respect:
200/// streaming fast-path → AST loader fallback → policy walk →
201/// `T::deserialize`.
202#[cfg(all(feature = "std", feature = "figment"))]
203pub(crate) fn from_str_typed_no_tag_preserve<T>(s: &str, config: &ParserConfig) -> Result<T>
204where
205 T: for<'de> serde_core::Deserialize<'de>,
206{
207 // Checked up front: the streaming walker's length error carries no
208 // location, so it would otherwise fall through to the AST path
209 // below, which does not check the length again.
210 if s.len() > config.max_document_length {
211 return Err(Error::Parse(format!(
212 "document exceeds maximum length of {} bytes",
213 config.max_document_length
214 )));
215 }
216 let stream_eligible = config.merge_key_policy == MergeKeyPolicy::Auto
217 && !config.ignore_binary_tag_for_string
218 && config.policies.is_empty()
219 && properties_inactive(config)
220 && includes_inactive(config);
221 let mut streaming_err = None;
222 if stream_eligible && let Some(res) = crate::streaming::from_str_streaming(s, config) {
223 match res {
224 // A typed rejection the streaming walker cannot locate --
225 // serde's own visitor errors (`invalid type: ...`) and the
226 // typed `TypeMismatch` arms carry no position on this path.
227 // Keep it and fall through to the span-aware AST path
228 // below, which finds where the same failure is;
229 // `locate_streaming_error` joins the two. Success, and parse
230 // errors that already carry a location, return as they are;
231 // the second parse is paid only on the error path.
232 Err(err) if err.location().is_none() => streaming_err = Some(err),
233 other => return other,
234 }
235 }
236 let parse_config = parser::ParseConfig::from(config);
237 let (mut value, span_tree) = parser::parse_exactly_one(s, &parse_config)?;
238 post_process_document(&mut value, config)?;
239 deserialize_spanned(s, &value, &span_tree, config, streaming_err)
240}
241
242/// Strict deserialise: like [`from_str`] but errors if `s`
243/// contains any keys that the target type `T` does not declare.
244///
245/// Solves the typo-in-config-key problem — by default, both
246/// `serde_yaml` and noyalib silently ignore keys the struct
247/// does not know about, so a misspelled `replicass: 3` (with
248/// the typo) deserialises into a struct whose `replicas` field
249/// stays at its `Default`. `from_str_strict` surfaces those
250/// extra keys as a typed `Error::UnknownField` listing every
251/// offending path.
252///
253/// # Errors
254///
255/// - Any key in the YAML document is not declared on `T`.
256/// - Any of the regular [`from_str`] error paths.
257///
258/// # Examples
259///
260/// ```
261/// #[derive(Debug, serde::Deserialize)]
262/// struct Config {
263/// port: u16,
264/// }
265///
266/// // The typo "porrt" is silently ignored by `from_str`. With
267/// // `from_str_strict` it surfaces as a typed error.
268/// let yaml = "port: 8080\nporrt: 9090\n";
269/// assert!(noyalib::from_str::<Config>(yaml).is_ok());
270/// assert!(noyalib::from_str_strict::<Config>(yaml).is_err());
271/// ```
272#[cfg(all(feature = "std", feature = "strict-deserialise"))]
273pub fn from_str_strict<T>(s: &str) -> Result<T>
274where
275 T: for<'de> serde_core::Deserialize<'de> + 'static,
276{
277 let unknown = std::sync::Mutex::new(Vec::<String>::new());
278 let value: Value = from_str_with_config(s, &ParserConfig::default())?;
279 let result: Result<T> = serde_ignored::deserialize(&value, |path| {
280 unknown
281 .lock()
282 .expect("from_str_strict: ignored-paths lock poisoned")
283 .push(path.to_string());
284 });
285 let extras = unknown
286 .into_inner()
287 .expect("from_str_strict: ignored-paths lock poisoned");
288 let typed = result?;
289 if !extras.is_empty() {
290 let msg = if extras.len() == 1 {
291 format!("unknown field at `{}`", extras[0])
292 } else {
293 let joined = extras
294 .iter()
295 .map(|p| format!("`{p}`"))
296 .collect::<Vec<_>>()
297 .join(", ");
298 format!("unknown fields: {joined}")
299 };
300 return Err(Error::UnknownField(msg));
301 }
302 Ok(typed)
303}
304
305/// Strict deserialise from a byte slice — like [`from_slice`] but
306/// errors if the input contains keys the target type `T` does not
307/// declare.
308///
309/// Same semantics as [`from_str_strict`]. Use this when the caller
310/// already holds a `&[u8]` (e.g. data read from a buffer or returned
311/// by a `bytes` framework) and would otherwise pay the UTF-8
312/// validation cost of converting to `&str` twice.
313///
314/// # Errors
315///
316/// - The byte slice is not valid UTF-8.
317/// - Any key in the YAML document is not declared on `T`.
318/// - Any of the regular [`from_slice`] error paths.
319///
320/// # Examples
321///
322/// ```
323/// #[derive(Debug, serde::Deserialize)]
324/// struct Config {
325/// port: u16,
326/// }
327///
328/// let yaml: &[u8] = b"port: 8080\nporrt: 9090\n";
329/// assert!(noyalib::from_slice::<Config>(yaml).is_ok());
330/// assert!(noyalib::from_slice_strict::<Config>(yaml).is_err());
331/// ```
332#[cfg(all(feature = "std", feature = "strict-deserialise"))]
333pub fn from_slice_strict<T>(b: &[u8]) -> Result<T>
334where
335 T: for<'de> serde_core::Deserialize<'de> + 'static,
336{
337 let s = core::str::from_utf8(b).map_err(|e| Error::Deserialize(e.to_string()))?;
338 from_str_strict(s)
339}
340
341/// Strict deserialise from an IO reader — like [`from_reader`] but
342/// errors if the input contains keys the target type `T` does not
343/// declare.
344///
345/// Same semantics as [`from_str_strict`]. Reads the entire stream
346/// into memory before parsing, mirroring [`from_reader`].
347///
348/// # Errors
349///
350/// - The reader returns an I/O error or the data is not valid UTF-8.
351/// - Any key in the YAML document is not declared on `T`.
352/// - Any of the regular [`from_reader`] error paths.
353///
354/// # Examples
355///
356/// ```
357/// #[derive(Debug, serde::Deserialize)]
358/// struct Config {
359/// port: u16,
360/// }
361///
362/// let yaml = b"port: 8080\nporrt: 9090\n".to_vec();
363/// assert!(noyalib::from_reader::<_, Config>(&yaml[..]).is_ok());
364/// assert!(noyalib::from_reader_strict::<_, Config>(&yaml[..]).is_err());
365/// ```
366#[cfg(all(feature = "std", feature = "strict-deserialise"))]
367pub fn from_reader_strict<R, T>(reader: R) -> Result<T>
368where
369 R: io::Read,
370 T: for<'de> serde_core::Deserialize<'de> + 'static,
371{
372 let s = read_to_string_bounded(reader, &ParserConfig::default())?;
373 from_str_strict(&s)
374}
375
376/// Read a `std::io::Read` source into a `String`, stopping one byte past
377/// the configured `max_document_length` so an unbounded or hostile
378/// reader cannot fill memory before the length budget is checked.
379///
380/// Returns the same `Error::Parse` message as the `&str` entry points
381/// when the limit is exceeded, and `Error::Io` (`InvalidData`) when the
382/// bytes are not valid UTF-8, matching `read_to_string`.
383#[cfg(feature = "std")]
384pub(crate) fn read_to_string_bounded<R>(mut reader: R, config: &ParserConfig) -> Result<String>
385where
386 R: io::Read,
387{
388 use io::Read as _;
389 let max_len = parser::ParseConfig::from(config).max_document_length;
390 let limit = u64::try_from(max_len).unwrap_or(u64::MAX).saturating_add(1);
391 let mut buf = Vec::new();
392 let _ = reader
393 .by_ref()
394 .take(limit)
395 .read_to_end(&mut buf)
396 .map_err(Error::Io)?;
397 if buf.len() > max_len {
398 return Err(Error::Parse(format!(
399 "document exceeds maximum length of {max_len} bytes"
400 )));
401 }
402 String::from_utf8(buf).map_err(|_| {
403 Error::Io(io::Error::new(
404 io::ErrorKind::InvalidData,
405 "stream did not contain valid UTF-8",
406 ))
407 })
408}
409
410/// Deserialize YAML from a `&str` with a custom [`ParserConfig`].
411///
412/// Use this when the defaults need overriding — common reasons:
413/// untrusted input ([`ParserConfig::strict()`]), pipeline-specific
414/// limits ([`ParserConfig::max_depth`]), YAML 1.1 compatibility
415/// ([`ParserConfig::version`]), or custom safe-YAML
416/// [`policy::Policy`](crate::policy::Policy) enforcement.
417///
418/// # Errors
419///
420/// Same variant set as [`from_str`]. The active `config` controls
421/// which limit-related errors can fire:
422///
423/// - When `config.duplicate_key_policy == DuplicateKeyPolicy::Error`,
424/// any duplicate mapping key returns `Error::DuplicateKey`.
425/// - When the input exceeds the configured `max_depth`,
426/// `max_document_length`, `max_alias_expansions`,
427/// `max_mapping_keys`, or `max_sequence_length`, the matching
428/// `Error::*Limit` variant is returned.
429/// - When `config.policies` contains a policy that rejects the
430/// document, `Error::Deserialize` is returned with the policy's
431/// diagnostic.
432///
433/// # Examples
434///
435/// ```
436/// use noyalib::{from_str_with_config, ParserConfig};
437/// let cfg = ParserConfig::strict();
438/// let n: i32 = from_str_with_config("7", &cfg).unwrap();
439/// assert_eq!(n, 7);
440/// ```
441pub fn from_str_with_config<T>(s: &str, config: &ParserConfig) -> Result<T>
442where
443 T: for<'de> serde_core::Deserialize<'de> + 'static,
444{
445 // Try streaming path first (faster, no intermediate Value AST).
446 // The streaming path bakes in YAML 1.2 semantics:
447 // - `<<: *alias` merges natively;
448 // - `!!binary` is propagated as a typed tag.
449 // When the caller asked for a non-default behaviour on either
450 // axis, route through the AST loader so the relevant toggle
451 // takes effect. Active `properties` interpolation also disables
452 // the streaming path so the post-parse substitution walk runs.
453 //
454 // The `Value` target is *always* excluded from the streaming path.
455 // Rationale: serde asks the streaming deserializer for `String`
456 // keys, which stringifies typed scalars *before*
457 // `ValueVisitor::visit_map` can tell `1` and `"1"` apart — the
458 // distinct-typed `KeyCollision` guard is unreachable there. The
459 // `Value`-target fast path a few lines down runs the span-free
460 // loader, which owns the collision check and the DoS budgets — and
461 // strips registry-registered tags itself (loader `resolve_untagged_scalar`),
462 // so an active tag registry no longer forces `Value` back onto the
463 // streaming path where the collision guard cannot run.
464 let value_target_bypass = is_value_target::<T>();
465 let stream_eligible = config.merge_key_policy == MergeKeyPolicy::Auto
466 && !config.ignore_binary_tag_for_string
467 && config.policies.is_empty()
468 && properties_inactive(config)
469 && includes_inactive(config)
470 && !value_target_bypass;
471 let mut streaming_err = None;
472 if stream_eligible && let Some(res) = crate::streaming::from_str_streaming(s, config) {
473 match res {
474 // A typed rejection the streaming walker cannot locate --
475 // serde's own visitor errors (`invalid type: ...`) and the
476 // typed `TypeMismatch` arms carry no position on this path.
477 // Keep it and fall through to the span-aware AST path
478 // below, which finds where the same failure is;
479 // `locate_streaming_error` joins the two. Success, and parse
480 // errors that already carry a location, return as they are;
481 // the second parse is paid only on the error path.
482 Err(err) if err.location().is_none() => streaming_err = Some(err),
483 other => return other,
484 }
485 }
486
487 let parse_config = parser::ParseConfig::from(config);
488
489 // The streaming path enforces `max_document_length` inline
490 // (`streaming::from_str_streaming` at head-of-fn). When the
491 // streaming path is skipped — either because the caller opted
492 // into a non-default policy or because T is `Value` — the check
493 // must fire here so the AST loader can't be walked over an
494 // oversized document.
495 if s.len() > parse_config.max_document_length {
496 return Err(Error::Parse(format!(
497 "document exceeds maximum length of {} bytes",
498 parse_config.max_document_length
499 )));
500 }
501
502 // Skip-span + zero-rewalk fast path: when T == Value, the AST
503 // we just parsed *is* the answer. The default path would
504 // (a) build a `SpanTree` that `Value::deserialize` never
505 // consults, then (b) hand the parsed `Value` back to serde,
506 // which walks it a second time and rebuilds an identical
507 // `Value` via `ValueVisitor::visit_seq` / `visit_map` — pure
508 // waste. Instead, parse via `parse_one_value` (no SpanTree)
509 // and downcast the `Value` directly into `T`. The downcast
510 // is the safe stdlib `Box<dyn Any>::downcast::<T>()` and is
511 // provably correct because `is_value_target::<T>()` already
512 // verified `TypeId::of::<T>() == TypeId::of::<Value>()`.
513 if is_value_target::<T>() {
514 let mut value = parser::parse_exactly_one_value(s, &parse_config)?;
515 post_process_document(&mut value, config)?;
516 let boxed: Box<dyn core::any::Any> = Box::new(value);
517 // SAFETY-by-construction: `is_value_target::<T>()` already
518 // verified `TypeId::of::<T>() == TypeId::of::<Value>()`, so
519 // the boxed `Value` downcasts to `T` infallibly. `.expect()`
520 // documents the invariant; the path is provably unreachable.
521 let downcast: Box<T> = boxed
522 .downcast::<T>()
523 .expect("is_value_target proved T == Value");
524 return Ok(*downcast);
525 }
526
527 #[cfg(feature = "std")]
528 {
529 let (mut value, span_tree) = parser::parse_exactly_one(s, &parse_config)?;
530 post_process_document(&mut value, config)?;
531 deserialize_spanned(s, &value, &span_tree, config, streaming_err)
532 }
533
534 #[cfg(not(feature = "std"))]
535 {
536 let mut value = parser::parse_exactly_one_value(s, &parse_config)?;
537 post_process_document(&mut value, config)?;
538 let de = Deserializer::with_options(
539 &value,
540 None,
541 config.ignore_binary_tag_for_string,
542 config.plain_scalar_strings,
543 );
544 locate_streaming_error(T::deserialize(de), streaming_err)
545 }
546}
547
548/// Deserialize `T` from a loaded `value` with the span context for
549/// `s` installed: errors carry locations and field paths, and keys the
550/// loader canonicalised read as written. `streaming_err` is the
551/// streaming path's unlocated error, joined to the result.
552#[cfg(feature = "std")]
553fn deserialize_spanned<T>(
554 s: &str,
555 value: &Value,
556 span_tree: &span_context::SpanTree,
557 config: &ParserConfig,
558 streaming_err: Option<Error>,
559) -> Result<T>
560where
561 T: for<'de> serde_core::Deserialize<'de>,
562{
563 let spans = span_context::build_span_map(value, span_tree);
564 let ctx = span_context::SpanContext::new(spans, s.into()).with_key_texts(value, span_tree);
565 let _guard = span_context::set_span_context(ctx);
566 let de = Deserializer::with_options(
567 value,
568 Some(_guard.as_ref()),
569 config.ignore_binary_tag_for_string,
570 config.plain_scalar_strings,
571 );
572 attach_field_path(
573 locate_streaming_error(T::deserialize(de), streaming_err),
574 value,
575 )
576}
577
578/// Resolves a typed parse whose streaming attempt failed without a
579/// position.
580///
581/// The streaming walker raises serde's own wording (`invalid type:
582/// string "x", expected u16`) but cannot say where; the span-aware AST
583/// path finds the position but describes the same failure in its own
584/// words (`type mismatch: expected unsigned integer, found string`). A
585/// caller must see one wording whichever path ran, so the result keeps
586/// the streaming message and takes only the location from the AST
587/// attempt. An AST attempt that succeeds is returned as it is; one that
588/// fails without a location yields the streaming error unchanged.
589fn locate_streaming_error<T>(ast_result: Result<T>, streaming_err: Option<Error>) -> Result<T> {
590 match (ast_result, streaming_err) {
591 // A refusal the AST loader can place and name -- a duplicate key
592 // or a key collision carrying its path and position -- is the
593 // better report of the fact the streaming walker refused without
594 // one; it is kept whole so `kind()` stays what it was (#378).
595 (Err(ast_err), Some(_))
596 if matches!(
597 ast_err.kind(),
598 crate::error::ErrorKind::DuplicateKey | crate::error::ErrorKind::KeyCollision
599 ) && ast_err.location().is_some() =>
600 {
601 Err(ast_err)
602 }
603 (Err(ast_err), Some(streaming_err)) => Err(match ast_err.location() {
604 Some(location) => {
605 let message = match streaming_err {
606 Error::Custom(message) | Error::Deserialize(message) => message,
607 other => other.to_string(),
608 };
609 Error::DeserializeWithLocation { message, location }
610 }
611 None => streaming_err,
612 }),
613 (result, _) => result,
614 }
615}
616
617/// Prefix a typed rejection's message with the path of the field it is
618/// about — `server.port: invalid type: …` — so an operator can search
619/// the config for the name, not just the line number (#353).
620///
621/// `wrap_err` records the failing node's address when it attaches a
622/// location; this resolves that address to a `a.b[2].c` path by walking
623/// `root` once, on the error path only. The happy path records and
624/// walks nothing. A failure at the document root keeps its message
625/// unprefixed (there is no path to name), as does any error shape that
626/// never went through `wrap_err`. `location()` is unaffected.
627#[cfg(feature = "std")]
628fn attach_field_path<T>(result: Result<T>, root: &Value) -> Result<T> {
629 // Always consume the slot: a rejection created and then swallowed
630 // (untagged-enum probing that ultimately succeeded) must not leak
631 // into a later error.
632 let recorded = span_context::take_error_node();
633 let err = match result {
634 Ok(v) => return Ok(v),
635 Err(err) => err,
636 };
637 let Some(addr) = recorded else {
638 return Err(err);
639 };
640 let mut segments = Vec::new();
641 if !find_value_path(root, addr, &mut segments) || segments.is_empty() {
642 return Err(err);
643 }
644 let path = format_value_path(&segments);
645 Err(match err {
646 Error::DeserializeWithLocation { message, location } => Error::DeserializeWithLocation {
647 message: format!("{path}: {message}"),
648 location,
649 },
650 Error::Deserialize(message) => Error::Deserialize(format!("{path}: {message}")),
651 Error::Custom(message) => Error::Custom(format!("{path}: {message}")),
652 other => other,
653 })
654}
655
656/// One step of a field path: a mapping key or a sequence index.
657#[cfg(feature = "std")]
658enum PathSegment<'a> {
659 Key(&'a str),
660 Index(usize),
661}
662
663/// Depth-first search for the node at address `addr`, pushing the
664/// mapping keys and sequence indices leading to it. Returns `true`
665/// when found, with `segments` holding the full path; on `false` the
666/// vector is back to its starting state. Tags are transparent — the
667/// path names the field, not the tag.
668#[cfg(feature = "std")]
669fn find_value_path<'a>(value: &'a Value, addr: usize, segments: &mut Vec<PathSegment<'a>>) -> bool {
670 if core::ptr::from_ref(value) as usize == addr {
671 return true;
672 }
673 match value {
674 Value::Mapping(map) => {
675 for (key, child) in map {
676 segments.push(PathSegment::Key(key));
677 if find_value_path(child, addr, segments) {
678 return true;
679 }
680 let _ = segments.pop();
681 }
682 }
683 Value::Sequence(seq) => {
684 for (index, child) in seq.iter().enumerate() {
685 segments.push(PathSegment::Index(index));
686 if find_value_path(child, addr, segments) {
687 return true;
688 }
689 let _ = segments.pop();
690 }
691 }
692 Value::Tagged(tagged) => return find_value_path(tagged.value(), addr, segments),
693 _ => {}
694 }
695 false
696}
697
698/// Render segments as `a.b[2].c`: keys joined with `.`, indices in
699/// brackets attached to the preceding segment (or leading, for a root
700/// sequence: `[2].name`).
701#[cfg(feature = "std")]
702fn format_value_path(segments: &[PathSegment<'_>]) -> String {
703 use core::fmt::Write as _;
704 let mut out = String::new();
705 for segment in segments {
706 match segment {
707 PathSegment::Key(key) => {
708 if !out.is_empty() {
709 out.push('.');
710 }
711 out.push_str(key);
712 }
713 PathSegment::Index(index) => {
714 let _ = write!(out, "[{index}]");
715 }
716 }
717 }
718 out
719}
720
721/// Returns `true` when no `${KEY}` substitution table is active —
722/// keeps the streaming fast-path eligible. Always `true` under
723/// `no_std` (the field doesn't exist).
724#[cfg(feature = "std")]
725#[inline]
726fn properties_inactive(config: &ParserConfig) -> bool {
727 config.properties.is_none()
728}
729
730#[cfg(not(feature = "std"))]
731#[inline]
732fn properties_inactive(_config: &ParserConfig) -> bool {
733 true
734}
735
736/// Walk the `Value` tree and substitute every `${name}` placeholder
737/// against `config.properties`. No-op when no map is installed; in
738/// `no_std` builds the function is also a no-op (the field doesn't
739/// exist). Honours `strict_properties` for the missing-key
740/// behaviour, but always propagates *syntax* errors from the
741/// substitution walk (invalid characters, unterminated `${...}`,
742/// malformed `:-default`) regardless of mode.
743/// Run the post-parse document pipeline (`!include` substitution, `${...}`
744/// property interpolation, and AST [`Policy::check_value`] hooks) in one
745/// canonical order across all loader entry points.
746pub(crate) fn post_process_document(value: &mut Value, config: &ParserConfig) -> Result<()> {
747 apply_includes(value, config)?;
748 apply_properties(value, config)?;
749 crate::policy::check_document(&config.policies, value)
750}
751
752#[cfg(feature = "std")]
753fn apply_properties(value: &mut Value, config: &ParserConfig) -> Result<()> {
754 if let Some(props) = config.properties.as_ref() {
755 let action = if config.strict_properties {
756 crate::value::MissingAction::Error(false)
757 } else {
758 crate::value::MissingAction::Empty
759 };
760 value.interpolate_inner_bounded(
761 &|name| match props.get(name) {
762 Some(v) => crate::value::ResolveOutcome::Found(v.clone()),
763 None => crate::value::ResolveOutcome::Missing,
764 },
765 action,
766 Some(config.max_total_scalar_bytes),
767 )?;
768 }
769 Ok(())
770}
771
772#[cfg(not(feature = "std"))]
773#[inline]
774fn apply_properties(_value: &mut Value, _config: &ParserConfig) -> Result<()> {
775 Ok(())
776}
777
778/// Returns `true` when no `!include` resolver is installed —
779/// keeps the streaming fast-path eligible.
780#[cfg(feature = "include")]
781#[inline]
782fn includes_inactive(config: &ParserConfig) -> bool {
783 config.include_resolver.is_none()
784}
785
786#[cfg(not(feature = "include"))]
787#[inline]
788fn includes_inactive(_config: &ParserConfig) -> bool {
789 true
790}
791
792#[cfg(all(feature = "include", feature = "std"))]
793type IncludeVisited = std::collections::HashSet<String>;
794#[cfg(all(feature = "include", not(feature = "std")))]
795type IncludeVisited = FxHashSet<String>;
796
797/// Walk the `Value` tree, find every `Value::Tagged(!include, _)`
798/// node, and replace it with the resolver's output. Cyclic
799/// includes are detected via a per-walk visited set; depth is
800/// bounded by `config.max_include_depth`. No-op when no resolver
801/// is installed.
802#[cfg(feature = "include")]
803fn apply_includes(value: &mut Value, config: &ParserConfig) -> Result<()> {
804 if let Some(resolver) = config.include_resolver.as_ref() {
805 let mut walk = IncludeWalk {
806 resolver,
807 parse_config: parser::ParseConfig::from(config),
808 max_include_depth: config.max_include_depth,
809 max_include_sources: config.max_include_sources,
810 max_total_include_bytes: config.max_total_include_bytes,
811 // Prelude set, not `std::collections`: `include` without
812 // `std` is a valid combination (the resolver trait is
813 // `no_std`; only `include_fs` implies `std`).
814 visited: IncludeVisited::default(),
815 next_id: 1,
816 sources: 0,
817 bytes: 0,
818 scalar_bytes: parser::meter::AliasCost::of_value(value).text,
819 };
820 walk.walk(value, IncludeAt::default())?;
821 enforce_expanded_node_budget(value, config.max_nodes, config.max_total_scalar_bytes)?;
822 }
823 Ok(())
824}
825
826/// Re-apply the parser's node and total scalar byte budgets after include substitution.
827///
828/// Every source is bounded independently while it is parsed, but replacing
829/// several `!include` scalars can make the combined tree exceed the caller's
830/// document budget. Keep the accounting aligned with `Loader`: collection
831/// nodes and mapping-key scalar events are charged, while a tag decorates its
832/// underlying YAML node rather than adding another node.
833#[cfg(feature = "include")]
834fn enforce_expanded_node_budget(
835 value: &Value,
836 max_nodes: usize,
837 max_total_scalar_bytes: usize,
838) -> Result<()> {
839 let mut pending = vec![value];
840 let mut observed = 0usize;
841 let mut scalar_bytes = 0usize;
842
843 while let Some(value) = pending.pop() {
844 let (charge, bytes) = match value {
845 Value::Tagged(tagged) => {
846 pending.push(tagged.value());
847 continue;
848 }
849 Value::Sequence(sequence) => {
850 pending.extend(sequence);
851 (1, 0)
852 }
853 Value::Mapping(mapping) => {
854 let mut key_bytes = 0usize;
855 for (key, item) in mapping {
856 key_bytes = key_bytes.saturating_add(key.len());
857 pending.push(item);
858 }
859 // The mapping itself and each authored scalar key are
860 // distinct parser events and therefore distinct nodes.
861 (1usize.saturating_add(mapping.len()), key_bytes)
862 }
863 Value::String(s) => (1, s.len()),
864 Value::Null | Value::Bool(_) | Value::Number(_) => (1, 0),
865 };
866
867 observed = observed.saturating_add(charge);
868 if observed > max_nodes {
869 return Err(Error::Budget(crate::BudgetBreach::MaxNodes {
870 limit: max_nodes,
871 observed,
872 }));
873 }
874 scalar_bytes = scalar_bytes.saturating_add(bytes);
875 if scalar_bytes > max_total_scalar_bytes {
876 return Err(Error::Budget(crate::BudgetBreach::MaxTotalScalarBytes {
877 limit: max_total_scalar_bytes,
878 observed: scalar_bytes,
879 }));
880 }
881 }
882
883 Ok(())
884}
885
886#[cfg(not(feature = "include"))]
887#[inline]
888fn apply_includes(_value: &mut Value, _config: &ParserConfig) -> Result<()> {
889 Ok(())
890}
891
892/// Where the include walk is: how many includes deep, which source,
893/// and how many collections enclose the current node in the final,
894/// substituted tree.
895#[cfg(feature = "include")]
896#[derive(Debug, Clone, Copy, Default)]
897struct IncludeAt {
898 include_depth: usize,
899 from_id: usize,
900 tree_depth: usize,
901}
902
903/// State of one `!include` resolution pass. Every budget is charged
904/// here, once per source, so no path through the walk can skip one.
905#[cfg(feature = "include")]
906struct IncludeWalk<'a> {
907 resolver: &'a crate::include::IncludeResolver,
908 parse_config: parser::ParseConfig,
909 max_include_depth: usize,
910 max_include_sources: usize,
911 max_total_include_bytes: usize,
912 visited: IncludeVisited,
913 next_id: usize,
914 sources: usize,
915 bytes: usize,
916 scalar_bytes: usize,
917}
918
919#[cfg(feature = "include")]
920impl IncludeWalk<'_> {
921 /// Replace every `!include` node under `value`. Recursion follows
922 /// the final tree's nesting, which `load` keeps within the parser's
923 /// `max_depth`, so the walk's own stack use is bounded too.
924 fn walk(&mut self, value: &mut Value, at: IncludeAt) -> Result<()> {
925 if let Value::Tagged(tagged) = value
926 && tagged.tag().as_str() == "!include"
927 {
928 let spec = tagged.value().as_str().map(str::to_owned).ok_or_else(|| {
929 Error::Custom("!include directive expects a scalar string spec".into())
930 })?;
931 self.scalar_bytes = self.scalar_bytes.saturating_sub(spec.len());
932 *value = self.load(&spec, at)?;
933 return Ok(());
934 }
935 let inner = IncludeAt {
936 tree_depth: at.tree_depth + 1,
937 ..at
938 };
939 match value {
940 Value::Tagged(tagged) => self.walk(tagged.value_mut(), at),
941 Value::Sequence(seq) => seq.iter_mut().try_for_each(|v| self.walk(v, inner)),
942 Value::Mapping(map) => map.values_mut().try_for_each(|v| self.walk(v, inner)),
943 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => Ok(()),
944 }
945 }
946
947 /// Resolve, charge and parse one include, then resolve the
948 /// includes inside it.
949 fn load(&mut self, spec: &str, at: IncludeAt) -> Result<Value> {
950 let include_depth = at.include_depth + 1;
951 if include_depth > self.max_include_depth {
952 return Err(Error::RecursionLimitExceeded {
953 depth: include_depth,
954 });
955 }
956 let remaining = self.max_total_include_bytes.saturating_sub(self.bytes);
957 let req = crate::include::IncludeRequest {
958 spec,
959 from_id: at.from_id,
960 depth: at.include_depth,
961 max_bytes: remaining.min(self.parse_config.max_document_length),
962 };
963 let source = self.resolver.resolve(req)?;
964 self.charge(&source)?;
965 let identity = source.name;
966 if !self.visited.insert(identity.clone()) {
967 return Err(Error::Custom(format!(
968 "!include cycle detected: canonical source `{identity}` is already in the resolution chain"
969 )));
970 }
971 let id = self.next_id;
972 self.next_id += 1;
973 // The included root takes the place of the `!include` node, so
974 // only the nesting left under the including document's position
975 // is available to it. `parse_exactly_one_value` rejects a
976 // multi-document include instead of truncating it (#351).
977 let mut source_config = self.parse_config.clone();
978 source_config.max_depth = source_config.max_depth.saturating_sub(at.tree_depth);
979 let mut included = parser::parse_exactly_one_value(&source.bytes, &source_config)?;
980 self.scalar_bytes = self
981 .scalar_bytes
982 .saturating_add(parser::meter::AliasCost::of_value(&included).text);
983 if self.scalar_bytes > self.parse_config.max_total_scalar_bytes {
984 return Err(Error::Budget(crate::BudgetBreach::MaxTotalScalarBytes {
985 limit: self.parse_config.max_total_scalar_bytes,
986 observed: self.scalar_bytes,
987 }));
988 }
989 let nested = IncludeAt {
990 include_depth,
991 from_id: id,
992 tree_depth: at.tree_depth,
993 };
994 self.walk(&mut included, nested)?;
995 let _ = self.visited.remove(&identity);
996 select_include_fragment(included, spec)
997 }
998
999 /// Charge one resolved source against the source-count, total-byte
1000 /// and per-document length budgets.
1001 fn charge(&mut self, source: &crate::include::InputSource) -> Result<()> {
1002 self.sources = self.sources.saturating_add(1);
1003 if self.sources > self.max_include_sources {
1004 return Err(Error::Budget(crate::BudgetBreach::MaxIncludeSources {
1005 limit: self.max_include_sources,
1006 observed: self.sources,
1007 }));
1008 }
1009 self.bytes = self.bytes.saturating_add(source.bytes.len());
1010 if self.bytes > self.max_total_include_bytes {
1011 return Err(Error::Budget(crate::BudgetBreach::MaxIncludeBytes {
1012 limit: self.max_total_include_bytes,
1013 observed: self.bytes,
1014 }));
1015 }
1016 let max_len = self.parse_config.max_document_length;
1017 if source.bytes.len() > max_len {
1018 return Err(Error::Parse(format!(
1019 "included document `{}` exceeds maximum length of {max_len} bytes",
1020 source.name
1021 )));
1022 }
1023 Ok(())
1024 }
1025}
1026
1027/// Narrow an included document to its `#fragment` key, when the spec
1028/// names one. Fragments resolve against mapping keys (the conventional
1029/// YAML "anchor-as-key" pattern); see `examples/include_directive.rs`.
1030#[cfg(feature = "include")]
1031fn select_include_fragment(included: Value, spec: &str) -> Result<Value> {
1032 let (path, fragment) = crate::include::split_fragment(spec);
1033 let Some(frag) = fragment else {
1034 return Ok(included);
1035 };
1036 let Some(map) = included.as_mapping() else {
1037 return Err(Error::Custom(format!(
1038 "!include fragment `#{frag}` requires a mapping-shaped \
1039 included document; `{path}` is not a mapping"
1040 )));
1041 };
1042 map.get(frag)
1043 .cloned()
1044 .ok_or_else(|| Error::Custom(format!("!include fragment `#{frag}` not found in `{path}`")))
1045}
1046
1047/// Deserialize YAML from a byte slice.
1048///
1049/// Convenience wrapper that validates `b` is UTF-8 then forwards
1050/// to [`from_str`]. Use when the caller already holds a `&[u8]`
1051/// (a buffer, a network frame, a `bytes::Bytes`) and would
1052/// otherwise have to round-trip through `String`.
1053///
1054/// # Errors
1055///
1056/// - `Error::Deserialize` — `b` is not valid UTF-8.
1057/// - All variants documented on [`from_str`].
1058///
1059/// # Examples
1060///
1061/// ```
1062/// let n: i32 = noyalib::from_slice(b"42").unwrap();
1063/// assert_eq!(n, 42);
1064/// ```
1065pub fn from_slice<T>(b: &[u8]) -> Result<T>
1066where
1067 T: for<'de> serde_core::Deserialize<'de> + 'static,
1068{
1069 let s = core::str::from_utf8(b).map_err(|e| Error::Deserialize(e.to_string()))?;
1070 from_str(s)
1071}
1072
1073/// Deserialize YAML from a byte slice with a custom [`ParserConfig`].
1074///
1075/// # Errors
1076///
1077/// - `Error::Deserialize` — `b` is not valid UTF-8.
1078/// - All variants documented on [`from_str_with_config`].
1079///
1080/// # Examples
1081///
1082/// ```
1083/// use noyalib::{from_slice_with_config, ParserConfig};
1084/// let cfg = ParserConfig::new();
1085/// let n: i32 = from_slice_with_config(b"7", &cfg).unwrap();
1086/// assert_eq!(n, 7);
1087/// ```
1088pub fn from_slice_with_config<T>(b: &[u8], config: &ParserConfig) -> Result<T>
1089where
1090 T: for<'de> serde_core::Deserialize<'de> + 'static,
1091{
1092 let s = core::str::from_utf8(b).map_err(|e| Error::Deserialize(e.to_string()))?;
1093 from_str_with_config(s, config)
1094}
1095
1096/// Deserialize YAML from an [`std::io::Read`] source.
1097///
1098/// Reads the entire stream into memory before parsing — YAML's
1099/// data model is not streamable past document boundaries, so this
1100/// function trades incremental I/O for a single, simple `Result`.
1101/// For very large multi-document streams prefer
1102/// [`crate::parallel::parse`] (with the `parallel` feature) which
1103/// scans document boundaries on the input thread and parses each
1104/// document in parallel.
1105///
1106/// # Errors
1107///
1108/// - `Error::Io` — the underlying reader returns an I/O error
1109/// while filling the buffer.
1110/// - All variants documented on [`from_str`].
1111///
1112/// # Examples
1113///
1114/// ```
1115/// let yaml = b"42".to_vec();
1116/// let n: i32 = noyalib::from_reader(&yaml[..]).unwrap();
1117/// assert_eq!(n, 42);
1118/// ```
1119#[cfg(feature = "std")]
1120#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
1121pub fn from_reader<R, T>(reader: R) -> Result<T>
1122where
1123 R: io::Read,
1124 T: for<'de> serde_core::Deserialize<'de> + 'static,
1125{
1126 from_reader_with_config(reader, &ParserConfig::default())
1127}
1128
1129/// Deserialize YAML from an [`std::io::Read`] source with a custom
1130/// [`ParserConfig`].
1131///
1132/// # Errors
1133///
1134/// - `Error::Io` — the underlying reader returns an I/O error.
1135/// - All variants documented on [`from_str_with_config`]. The reader
1136/// is read at most one byte past `max_document_length`, so an
1137/// oversized or unbounded source fails the length budget without
1138/// being buffered first.
1139///
1140/// # Examples
1141///
1142/// ```
1143/// use noyalib::{from_reader_with_config, ParserConfig};
1144/// let cfg = ParserConfig::new();
1145/// let bytes = b"7".to_vec();
1146/// let n: i32 = from_reader_with_config(&bytes[..], &cfg).unwrap();
1147/// assert_eq!(n, 7);
1148/// ```
1149#[cfg(feature = "std")]
1150#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
1151pub fn from_reader_with_config<R, T>(reader: R, config: &ParserConfig) -> Result<T>
1152where
1153 R: io::Read,
1154 T: for<'de> serde_core::Deserialize<'de> + 'static,
1155{
1156 let s = read_to_string_bounded(reader, config)?;
1157 from_str_with_config(&s, config)
1158}
1159
1160/// Deserialize a [`Value`] into a typed `T` via Serde's data model.
1161///
1162/// Useful for second-pass conversion when the first pass parsed
1163/// into the dynamic [`Value`] tree and a typed view is now needed
1164/// for a sub-tree.
1165///
1166/// # Errors
1167///
1168/// - `Error::Deserialize` — `value` does not match `T`'s shape.
1169/// - `Error::Custom` — surfaces upstream `serde_core::de::Error`
1170/// conversions that don't fit the structured variants.
1171///
1172/// # Examples
1173///
1174/// ```
1175/// use noyalib::{from_value, Value};
1176/// let v = Value::from(42_i64);
1177/// let n: i32 = from_value(&v).unwrap();
1178/// assert_eq!(n, 42);
1179/// ```
1180pub fn from_value<T>(value: &Value) -> Result<T>
1181where
1182 T: for<'de> serde_core::Deserialize<'de> + 'static,
1183{
1184 from_value_with_options(value, false, false)
1185}
1186
1187/// Deserialize `T` from a [`Value`] while honouring [`ParserConfig`]
1188/// deserialiser toggles (`ignore_binary_tag_for_string` and
1189/// `plain_scalar_strings`).
1190pub(crate) fn from_value_with_options<T>(
1191 value: &Value,
1192 ignore_binary_tag_for_string: bool,
1193 plain_scalar_strings: bool,
1194) -> Result<T>
1195where
1196 T: for<'de> serde_core::Deserialize<'de> + 'static,
1197{
1198 // Zero-rewalk fast path: when T == Value, the answer is just
1199 // `value.clone()`. The default `T::deserialize(de)` route
1200 // walks `value` and reconstructs an identical Value via
1201 // `ValueVisitor::visit_seq`/`visit_map` — pure waste.
1202 if is_value_target::<T>() {
1203 let cloned = value.clone();
1204 let boxed: Box<dyn core::any::Any> = Box::new(cloned);
1205 let downcast: Box<T> = boxed
1206 .downcast::<T>()
1207 .expect("is_value_target proved T == Value");
1208 return Ok(*downcast);
1209 }
1210 let de = Deserializer::with_options(
1211 value,
1212 None,
1213 ignore_binary_tag_for_string,
1214 plain_scalar_strings,
1215 );
1216 T::deserialize(de)
1217}
1218
1219/// Deserialize `T` from a [`Value`] with an active [`span_context::SpanContext`]
1220/// and [`ParserConfig`] deserialiser toggles installed.
1221#[cfg(feature = "std")]
1222pub(crate) fn from_value_spanned_with_options<T>(
1223 value: &Value,
1224 ctx: span_context::SpanContext,
1225 ignore_binary_tag_for_string: bool,
1226 plain_scalar_strings: bool,
1227) -> Result<T>
1228where
1229 T: for<'de> serde_core::Deserialize<'de> + 'static,
1230{
1231 if is_value_target::<T>() {
1232 let cloned = value.clone();
1233 let boxed: Box<dyn core::any::Any> = Box::new(cloned);
1234 let downcast: Box<T> = boxed
1235 .downcast::<T>()
1236 .expect("is_value_target proved T == Value");
1237 return Ok(*downcast);
1238 }
1239 let _guard = span_context::set_span_context(ctx);
1240 let de = Deserializer::with_options(
1241 value,
1242 Some(_guard.as_ref()),
1243 ignore_binary_tag_for_string,
1244 plain_scalar_strings,
1245 );
1246 attach_field_path(T::deserialize(de), value)
1247}
1248
1249// The figment integration entry `from_str_typed_no_tag_preserve` is
1250// `pub(crate)`, reachable only from within this crate (figment's
1251// `Format::from_str` calls it). These unit tests drive it directly so
1252// both of its arms are exercised: the streaming fast-path
1253// (`return res`) and the AST fallback taken when the streaming layer
1254// defers (e.g. on a merge key). Integration tests can't reach a
1255// `pub(crate)` fn, so this is the only home for the coverage.
1256#[cfg(all(test, feature = "std", feature = "figment"))]
1257mod figment_entry_tests {
1258 use super::*;
1259
1260 #[derive(Debug, serde::Deserialize, PartialEq)]
1261 struct Endpoint {
1262 name: String,
1263 port: u16,
1264 }
1265
1266 #[test]
1267 fn no_tag_preserve_takes_streaming_fast_path() {
1268 // A plain mapping is handled entirely by the streaming layer,
1269 // so `from_str_streaming` returns `Some(..)` and the function
1270 // returns through the fast-path arm.
1271 let cfg = ParserConfig::default();
1272 let got: Endpoint = from_str_typed_no_tag_preserve("name: alpha\nport: 7\n", &cfg).unwrap();
1273 assert_eq!(
1274 got,
1275 Endpoint {
1276 name: "alpha".into(),
1277 port: 7
1278 }
1279 );
1280 }
1281
1282 #[test]
1283 fn no_tag_preserve_falls_back_to_ast_on_merge_key() {
1284 // A merge key makes the streaming layer defer, so
1285 // `from_str_streaming` yields `None` and the function takes the
1286 // AST loader + policy-walk fallback arm. The merged result must
1287 // still deserialise identically.
1288 let cfg = ParserConfig::default();
1289 let yaml = "\
1290_defaults: &d\n name: beta\n port: 9\n<<: *d\n";
1291 let got: Endpoint = from_str_typed_no_tag_preserve(yaml, &cfg).unwrap();
1292 assert_eq!(
1293 got,
1294 Endpoint {
1295 name: "beta".into(),
1296 port: 9
1297 }
1298 );
1299 }
1300
1301 #[test]
1302 fn no_tag_preserve_surfaces_parse_error() {
1303 // Over-long input trips the document-length guard before either
1304 // path — exercises the error return the figment provider maps
1305 // into a `FigmentError`.
1306 let cfg = ParserConfig::default().max_document_length(4);
1307 let res: Result<Endpoint> = from_str_typed_no_tag_preserve("name: alpha\n", &cfg);
1308 assert!(matches!(res, Err(Error::Parse(_))), "got {res:?}");
1309 }
1310
1311 #[test]
1312 fn no_tag_preserve_runs_value_policies() {
1313 // A non-empty policy set makes the streaming path ineligible
1314 // (it requires `policies.is_empty()`), forcing the AST branch
1315 // and exercising the post-load policy walk
1316 // (`for p in &config.policies { p.check_value(..)? }`) plus its
1317 // error-propagation edge.
1318 #[derive(Debug)]
1319 struct RejectAll;
1320 impl crate::policy::Policy for RejectAll {
1321 fn check_value(&self, _v: &Value) -> Result<()> {
1322 Err(Error::Deserialize("rejected by policy".into()))
1323 }
1324 }
1325 let cfg = ParserConfig::default().with_policy(RejectAll);
1326 let res: Result<Endpoint> = from_str_typed_no_tag_preserve("name: alpha\nport: 7\n", &cfg);
1327 assert!(
1328 matches!(res, Err(Error::Deserialize(_))),
1329 "policy rejection must surface: {res:?}"
1330 );
1331 }
1332}