Skip to main content

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}