Skip to main content

noyalib/de/
config.rs

1//! Parser configuration types.
2
3// SPDX-License-Identifier: MIT OR Apache-2.0
4// Copyright (c) 2026 Noyalib. All rights reserved.
5
6use crate::prelude::*;
7
8/// Which version of the YAML specification the resolver follows.
9///
10/// YAML 1.2 (the default) and 1.1 differ in their plain-scalar
11/// resolution table:
12///
13/// | Form | 1.2 (core schema) | 1.1 |
14/// |---|---|---|
15/// | `yes` / `no` / `on` / `off` | string | bool |
16/// | `0644` | int 644 (decimal) | int 420 (octal) |
17/// | `60:00` | string | int 3 600 (sexagesimal) |
18/// | `.nan` / `.inf` | float | float (same) |
19/// | `true` / `false` | bool | bool (same) |
20///
21/// Selecting a version is a preset over the three `legacy_*` flags;
22/// see [`ParserConfig::version`] for the full mapping.
23///
24/// # Examples
25///
26/// ```
27/// use noyalib::{from_str_with_config, ParserConfig, Value, YamlVersion};
28///
29/// let cfg = ParserConfig::new().version(YamlVersion::V1_1);
30/// let v: Value = from_str_with_config("yes", &cfg).unwrap();
31/// assert_eq!(v, Value::Bool(true));
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34#[non_exhaustive]
35pub enum YamlVersion {
36    /// YAML 1.2 (2009) core schema. Default. Strict `true` / `false`
37    /// booleans; no bare octal; no sexagesimal.
38    #[default]
39    V1_2,
40    /// YAML 1.1 (2005). Broad resolver: `yes` / `no` / `on` / `off`
41    /// are booleans; `0644` is octal; `60:00` is sexagesimal.
42    V1_1,
43}
44
45/// Stable parser configuration presets.
46///
47/// Profiles select a complete semantic and resource-limit baseline. Use
48/// [`ParserConfig::profile`] when configuration must be named explicitly,
49/// such as at a service trust boundary. Callers can still refine individual
50/// settings with the existing builder methods.
51///
52/// # Examples
53///
54/// ```
55/// use noyalib::{ParserConfig, ParserProfile};
56///
57/// let cfg = ParserConfig::profile(ParserProfile::Strict);
58/// assert_eq!(cfg.max_depth, 64);
59/// ```
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61#[non_exhaustive]
62pub enum ParserProfile {
63    /// YAML 1.2 semantics with the standard resource budgets.
64    #[default]
65    Standard,
66    /// YAML 1.2 semantics with tighter budgets and validation for untrusted
67    /// input.
68    Strict,
69    /// Compatibility semantics matching `serde_yaml` 0.9.
70    ///
71    /// This profile retains the standard resource budgets. It is a migration
72    /// profile, not a hardened-input profile.
73    SerdeYaml,
74}
75
76/// Resource budgets enforced while parsing YAML.
77///
78/// This value separates denial-of-service controls from YAML semantics and
79/// post-parse integrations. Apply a complete budget set with
80/// [`ParserConfig::with_limits`], or read the active budgets with
81/// [`ParserConfig::limits`]. Existing `ParserConfig` limit fields remain
82/// public for backward compatibility.
83///
84/// # Examples
85///
86/// ```
87/// use noyalib::{ParserConfig, ParserLimits};
88///
89/// let mut limits = ParserLimits::strict();
90/// limits.max_document_length = 8 * 1024;
91/// let cfg = ParserConfig::new().with_limits(limits);
92/// assert_eq!(cfg.max_document_length, 8 * 1024);
93/// ```
94#[derive(Debug, Clone, Copy, PartialEq)]
95#[non_exhaustive]
96pub struct ParserLimits {
97    /// Maximum YAML structural recursion depth.
98    pub max_depth: usize,
99    /// Maximum bytes in one YAML document.
100    pub max_document_length: usize,
101    /// Maximum bytes in one multi-document stream.
102    pub max_stream_bytes: usize,
103    /// Maximum expansions of one anchor.
104    pub max_alias_expansions: usize,
105    /// Maximum keys in one mapping.
106    pub max_mapping_keys: usize,
107    /// Maximum elements in one sequence.
108    pub max_sequence_length: usize,
109    /// Maximum parser events across the input.
110    pub max_events: usize,
111    /// Maximum `Value` nodes across the input.
112    pub max_nodes: usize,
113    /// Maximum scalar bytes after alias expansion.
114    pub max_total_scalar_bytes: usize,
115    /// Maximum documents in one stream.
116    pub max_documents: usize,
117    /// Maximum merge-key entries across one document.
118    pub max_merge_keys: usize,
119    /// Optional maximum alias-to-anchor ratio.
120    pub alias_anchor_ratio: Option<f64>,
121    /// Optional transitive alias-expansion factor per parser event.
122    pub alias_jump_event_factor: Option<usize>,
123    /// Maximum `!include` recursion depth.
124    #[cfg(feature = "include")]
125    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
126    pub max_include_depth: usize,
127    /// Maximum sources resolved by one include walk.
128    #[cfg(feature = "include")]
129    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
130    pub max_include_sources: usize,
131    /// Maximum cumulative bytes returned by include resolvers.
132    #[cfg(feature = "include")]
133    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
134    pub max_total_include_bytes: usize,
135}
136
137impl Default for ParserLimits {
138    fn default() -> Self {
139        Self {
140            max_depth: 128,
141            max_document_length: 64 * 1024 * 1024,
142            max_stream_bytes: 256 * 1024 * 1024,
143            max_alias_expansions: 1_024,
144            max_mapping_keys: 64 * 1024,
145            max_sequence_length: 64 * 1024,
146            max_events: 1_000_000,
147            max_nodes: 250_000,
148            max_total_scalar_bytes: 64 * 1024 * 1024,
149            max_documents: 1_000,
150            max_merge_keys: 10_000,
151            alias_anchor_ratio: Some(10.0),
152            alias_jump_event_factor: None,
153            #[cfg(feature = "include")]
154            max_include_depth: 24,
155            #[cfg(feature = "include")]
156            max_include_sources: 256,
157            #[cfg(feature = "include")]
158            max_total_include_bytes: 64 * 1024 * 1024,
159        }
160    }
161}
162
163impl ParserLimits {
164    /// Return the resource budgets associated with a named parser profile.
165    ///
166    /// The `SerdeYaml` profile uses the standard absolute budgets plus the
167    /// compatibility layer's transitive alias-expansion factor.
168    #[must_use]
169    pub fn profile(profile: ParserProfile) -> Self {
170        match profile {
171            ParserProfile::Standard => Self::default(),
172            ParserProfile::Strict => Self::strict(),
173            ParserProfile::SerdeYaml => Self {
174                alias_jump_event_factor: Some(100),
175                ..Self::default()
176            },
177        }
178    }
179
180    /// Return the tighter budgets used by [`ParserProfile::Strict`].
181    #[must_use]
182    pub const fn strict() -> Self {
183        Self {
184            max_depth: 64,
185            max_document_length: 1024 * 1024,
186            max_stream_bytes: 16 * 1024 * 1024,
187            max_alias_expansions: 100,
188            max_mapping_keys: 1_024,
189            max_sequence_length: 1_024,
190            max_events: 100_000,
191            max_nodes: 25_000,
192            max_total_scalar_bytes: 1024 * 1024,
193            max_documents: 100,
194            max_merge_keys: 1_000,
195            alias_anchor_ratio: Some(5.0),
196            alias_jump_event_factor: None,
197            #[cfg(feature = "include")]
198            max_include_depth: 8,
199            #[cfg(feature = "include")]
200            max_include_sources: 32,
201            #[cfg(feature = "include")]
202            max_total_include_bytes: 1024 * 1024,
203        }
204    }
205}
206
207/// Deserialization configuration.
208///
209/// All fields are public, but the struct is annotated
210/// [`#[non_exhaustive]`][nex] so that adding a new budget or
211/// policy in a future minor release is **not** a breaking change.
212/// Construct with [`ParserConfig::new`] / [`ParserConfig::strict`]
213/// / [`ParserConfig::default`] (preferred) or with the
214/// `..ParserConfig::default()` struct-update form; do not
215/// construct from an exhaustive struct-literal outside this
216/// crate.
217///
218/// [nex]: https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute
219///
220/// # Examples
221///
222/// ```
223/// use noyalib::ParserConfig;
224/// let cfg = ParserConfig::new().max_depth(64);
225/// assert_eq!(cfg.max_depth, 64);
226/// ```
227#[derive(Debug, Clone)]
228#[non_exhaustive]
229pub struct ParserConfig {
230    /// Which YAML specification version to honour during plain-scalar
231    /// resolution.
232    ///
233    /// YAML 1.2 (default) follows the **core schema** — strict
234    /// `true`/`false` booleans, no bare `0`-prefix octal, no
235    /// sexagesimal `60:00` integers. YAML 1.1 broadens the resolver
236    /// to accept all of those legacy forms.
237    ///
238    /// Setting this to [`YamlVersion::V1_1`] is equivalent to flipping
239    /// every `legacy_*` flag (`legacy_booleans`, `legacy_octal_numbers`,
240    /// `legacy_sexagesimal`) on at once. The `legacy_*` flags remain
241    /// available for fine-grained overrides — version selection sets a
242    /// preset, individual flags refine it.
243    pub yaml_version: YamlVersion,
244    /// Maximum recursion depth allowed during parsing (default: 128).
245    pub max_depth: usize,
246    /// Maximum length of a single YAML document in bytes (default: 64 MB).
247    pub max_document_length: usize,
248    /// Maximum total byte length of a multi-document stream
249    /// (default: 256 MB). Async multi-document readers enforce this
250    /// before splitting, while each document remains subject to
251    /// [`Self::max_document_length`].
252    pub max_stream_bytes: usize,
253    /// Maximum number of times a single anchor can be expanded (default: 1024).
254    pub max_alias_expansions: usize,
255    /// Maximum number of keys allowed in a single mapping (default: 64k).
256    pub max_mapping_keys: usize,
257    /// Maximum number of elements allowed in a single sequence (default: 64k).
258    pub max_sequence_length: usize,
259    /// Maximum total parser events emitted across the input
260    /// (default: 1 000 000). Caps event-stream amplification
261    /// independent of recursion depth or alias count. Trips
262    /// [`crate::Error::Budget`] with
263    /// [`crate::BudgetBreach::MaxEvents`].
264    pub max_events: usize,
265    /// Maximum total `Value` nodes authored into the AST across the
266    /// input (default: 250 000). Each scalar, sequence, and mapping —
267    /// empty collections included — counts as one node, so this bounds
268    /// node-dense payloads (long runs of `[]`/`{}`) that stay under the
269    /// scalar-byte and event caps. Trips [`crate::Error::Budget`] with
270    /// [`crate::BudgetBreach::MaxNodes`]. Enforced on the AST-loader
271    /// path and re-applied to the fully expanded include graph; raise it
272    /// for deliberately large documents.
273    pub max_nodes: usize,
274    /// Maximum cumulative scalar-byte count across the document
275    /// (default: 64 MB). Distinct from
276    /// [`Self::max_document_length`] (input size) — this caps
277    /// scalar payload after alias expansion. Trips
278    /// [`crate::BudgetBreach::MaxTotalScalarBytes`].
279    pub max_total_scalar_bytes: usize,
280    /// Maximum number of documents in a multi-document stream
281    /// (default: 1 000). Trips
282    /// [`crate::BudgetBreach::MaxDocuments`].
283    pub max_documents: usize,
284    /// Maximum number of merge-key (`<<`) entries across the
285    /// document (default: 10 000). Trips
286    /// [`crate::BudgetBreach::MaxMergeKeys`].
287    pub max_merge_keys: usize,
288    /// Optional alias-to-anchor ratio heuristic for detecting
289    /// billion-laughs amplification patterns
290    /// (default: `Some(10.0)`). When more than `ratio × anchors`
291    /// aliases have been resolved, the parser trips
292    /// [`crate::BudgetBreach::AliasAnchorRatio`]. Set to `None`
293    /// to disable.
294    pub alias_anchor_ratio: Option<f64>,
295    /// How to handle duplicate keys in a mapping (default: Last, per YAML 1.2).
296    pub duplicate_key_policy: DuplicateKeyPolicy,
297    /// If true, only `true` and `false` (lowercase) are accepted as booleans.
298    pub strict_booleans: bool,
299    /// If true, accepts YAML 1.1 booleans like `yes`, `no`, `on`, `off`.
300    pub legacy_booleans: bool,
301    /// Optional registry of custom tags to strip on the streaming path.
302    ///
303    /// See [`TagRegistry`](crate::TagRegistry) for the full rationale.
304    /// `None` (default) preserves the legacy behaviour of routing every
305    /// custom-tagged value through the AST fallback.
306    pub tag_registry: Option<Arc<crate::TagRegistry>>,
307    /// How the YAML merge key (`<<`) should be handled.
308    ///
309    /// See [`MergeKeyPolicy`] for the available policies. The
310    /// default is [`MergeKeyPolicy::Auto`] — the YAML 1.2 spec
311    /// behaviour where `<<:` triggers automatic mapping merge.
312    pub merge_key_policy: MergeKeyPolicy,
313    /// When `true`, plain scalars are *never* resolved to
314    /// `null` / `bool` / `int` / `float` — every plain scalar
315    /// becomes a string. Useful for schema-strict pipelines that
316    /// require the user to quote intent explicitly. Default
317    /// `false`.
318    pub no_schema: bool,
319    /// When `true`, accept YAML 1.1-style bare `0`-prefix octal
320    /// literals (e.g. `0644` parsed as 420) in addition to the
321    /// YAML 1.2 `0o644` form. Default `false` to honour the YAML
322    /// 1.2 schema.
323    pub legacy_octal_numbers: bool,
324    /// When `true`, deserializing `!!binary "ABCD"` into a
325    /// [`String`] target yields the literal base64 source string
326    /// (`"ABCD"`) rather than rejecting on tag mismatch. The
327    /// canonical bytes path (`Vec<u8>`,
328    /// `serde_bytes::ByteBuf`) still decodes the base64 payload
329    /// either way. Useful for migrations from Python pyyaml-style
330    /// applications that treat the tag as advisory. Default
331    /// `false`.
332    pub ignore_binary_tag_for_string: bool,
333    /// When enabled, a plain scalar deserializes into a [`String`]
334    /// (or `char`) target as its source text even where the YAML 1.2
335    /// schema resolves it to a number, boolean, or null:
336    /// `password: 123456` gives `"123456"`, `~` gives `"~"`, an empty
337    /// value gives `""`. Off by default: a `String` field refuses a
338    /// non-string plain scalar. Quoted scalars are strings either
339    /// way. Matches what serde_yaml did for typed targets.
340    pub plain_scalar_strings: bool,
341    /// When `true`, accept YAML 1.1-style **sexagesimal** numbers
342    /// (`60:00`, `1:30:00`) as integers. The colon-separated
343    /// digits are interpreted in base 60: each component is
344    /// multiplied by an increasing power of 60, summed left to
345    /// right. `60:00` → 3 600; `1:30:00` → 5 400. Negative values
346    /// (`-1:30:00`) and partial signs are honoured.
347    ///
348    /// Off by default to honour the YAML 1.2 schema. Useful for
349    /// migrations from YAML 1.1 / Ruby / pyyaml configs that use
350    /// the legacy time-of-day notation.
351    pub legacy_sexagesimal: bool,
352    /// When `true`, and the `lossless-u64` Cargo feature is enabled,
353    /// YAML integer scalars in `(i64::MAX, u64::MAX]` resolve as
354    /// unsigned integers instead of falling through to `f64`.
355    ///
356    /// Default `false` to preserve the historical public
357    /// `Integer(i64)` / `Float(f64)` model and serde-yaml compatibility.
358    #[cfg(feature = "lossless-u64")]
359    #[cfg_attr(docsrs, doc(cfg(feature = "lossless-u64")))]
360    pub lossless_u64_integers: bool,
361    /// When `true`, a plain decimal integer with a leading zero
362    /// (`0123`, `+007`) resolves as a **string** instead of a
363    /// number. This is what `serde_yaml` 0.9 (libyaml) did — the
364    /// spelling is octal in YAML 1.1 and decimal in 1.2, and
365    /// libyaml sidestepped the ambiguity by resolving neither.
366    /// `0`, `0o755`, and `0x1F` are unaffected. Default `false`
367    /// (YAML 1.2: a decimal integer).
368    pub leading_zero_integer_strings: bool,
369    /// When `true`, accept YAML 1.1-style binary literals
370    /// (`0b11` → 3). Default `false` to honour the YAML 1.2
371    /// schema, which has no binary form.
372    pub legacy_binary_numbers: bool,
373    /// When `true`, a plain scalar float whose *literal* spelling
374    /// overflows `f64` (`1e999`) resolves as a **string** instead
375    /// of infinity. The explicit spellings `.inf` / `-.Inf` /
376    /// `.nan` still resolve to their float values. Matches
377    /// `serde_yaml` 0.9. Default `false` (overflow saturates to
378    /// infinity, the Rust float-parsing convention).
379    pub float_overflow_strings: bool,
380    /// When `true`, a plain decimal integer beyond `u64::MAX`
381    /// (`18446744073709551616`) aborts the parse with
382    /// [`ErrorKind::IntegerOverflow`](crate::ErrorKind) instead
383    /// of falling through to an approximate `f64`. Matches
384    /// `serde_yaml` 0.9's "JSON number out of range". Default
385    /// `false`.
386    pub integer_overflow_errors: bool,
387    /// What to do with a non-scalar mapping key (`[a, b]: v`,
388    /// `{k: v}: w`). See [`NonScalarKeyPolicy`]. Default
389    /// [`NonScalarKeyPolicy::Stringify`] — the key is converted
390    /// to its deterministic string form.
391    pub non_scalar_key_policy: NonScalarKeyPolicy,
392    /// Transitive alias-expansion budget as a multiple of the
393    /// document's own event count, mirroring `serde_yaml` 0.9's
394    /// rule (its deserializer refuses once alias jumps exceed
395    /// `events × 100` with "repetition limit exceeded"). Each
396    /// alias expansion charges the full node count of the anchored
397    /// subtree, so nested anchors multiply. `None` (default)
398    /// disables the factor check; the absolute
399    /// [`Self::max_alias_expansions`] and byte budgets still
400    /// apply.
401    pub alias_jump_event_factor: Option<usize>,
402    /// Indentation-validation mode. See [`RequireIndent`].
403    /// Default: [`RequireIndent::Unchecked`] — accept any
404    /// well-formed YAML indent.
405    pub require_indent: RequireIndent,
406    /// Pluggable "Safe YAML" policies, run during parsing.
407    ///
408    /// Each [`Policy`](crate::policy::Policy) inspects parser
409    /// events and the post-parse [`Value`](crate::Value) tree; any policy
410    /// returning `Err(...)` aborts the parse with that diagnostic.
411    /// Empty by default.
412    ///
413    /// Use [`ParserConfig::with_policy`] to register a policy.
414    /// When at least one policy is present the streaming fast-path
415    /// is bypassed automatically so the policy contract holds for
416    /// every code path.
417    pub policies: Vec<Arc<dyn crate::policy::Policy>>,
418    /// `${KEY}` / `${KEY:-default}` substitution table consulted
419    /// after parsing every document.
420    ///
421    /// Each scalar in the resulting [`Value`](crate::Value) tree is walked and
422    /// any `${name}` placeholder is replaced with the property of
423    /// that name. Supported syntax:
424    ///
425    /// - `${name}` — substitute, error or pass through depending
426    ///   on [`Self::strict_properties`]
427    /// - `${name:-default}` — substitute, falling back to
428    ///   `default` when `name` is missing (always silent, never
429    ///   surfaces in errors)
430    /// - `${{` — literal `${` (escape for the open delimiter)
431    /// - `$$` — literal `$`
432    /// - `}}` — literal `}`
433    ///
434    /// `None` (default) disables the substitution pass entirely;
435    /// the parser is unchanged. Setting a non-empty map forces the
436    /// AST fallback so the post-parse walk runs uniformly across
437    /// every typed target.
438    #[cfg(feature = "std")]
439    #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
440    pub properties: Option<Arc<std::collections::HashMap<String, String>>>,
441    /// When `true`, an unknown `${name}` placeholder (no entry in
442    /// [`Self::properties`] and no `:-default` fallback) aborts
443    /// the parse with [`Error::Custom`](crate::Error::Custom).
444    /// When `false` (default), unknown placeholders are replaced
445    /// with the empty string — the lossy semantics matching
446    /// [`Value::interpolate_properties_lossy`](crate::Value::interpolate_properties_lossy).
447    #[cfg(feature = "std")]
448    #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
449    pub strict_properties: bool,
450    /// `!include` directive resolver. When set, the post-parse
451    /// walk substitutes every `Value::Tagged(!include, spec)`
452    /// node with the result of `resolver(IncludeRequest)`. See
453    /// [`crate::include::IncludeResolver`] for the closure
454    /// signature and [`crate::include::SafeFileResolver`] for
455    /// the bundled filesystem implementation.
456    ///
457    /// `None` (default) disables include expansion; tagged
458    /// `!include` nodes flow through unchanged.
459    #[cfg(feature = "include")]
460    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
461    pub include_resolver: Option<crate::include::IncludeResolver>,
462    /// Maximum `!include` recursion depth. Default 24. Each
463    /// nested `!include` increments the depth counter; once the
464    /// limit is reached, the parser aborts with
465    /// `Error::RecursionLimitExceeded`. Pairs with a per-walk
466    /// visited-set to catch cycles independent of depth.
467    #[cfg(feature = "include")]
468    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
469    pub max_include_depth: usize,
470    /// Maximum number of sources resolved during one include walk.
471    /// Default 256.
472    #[cfg(feature = "include")]
473    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
474    pub max_include_sources: usize,
475    /// Maximum cumulative bytes returned by include resolvers during one
476    /// include walk. Default 64 MiB.
477    #[cfg(feature = "include")]
478    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
479    pub max_total_include_bytes: usize,
480    /// Stream-wide budget counters, attached by the multi-document entry
481    /// points that parse each document separately so the stream-wide
482    /// budgets keep their meaning. Never set by callers.
483    pub(crate) stream_tally: Option<Arc<crate::parser::meter::StreamTally>>,
484}
485
486impl Default for ParserConfig {
487    fn default() -> Self {
488        let limits = ParserLimits::default();
489        Self {
490            yaml_version: YamlVersion::V1_2,
491            max_depth: limits.max_depth,
492            max_document_length: limits.max_document_length,
493            max_stream_bytes: limits.max_stream_bytes,
494            max_alias_expansions: limits.max_alias_expansions,
495            max_mapping_keys: limits.max_mapping_keys,
496            max_sequence_length: limits.max_sequence_length,
497            max_events: limits.max_events,
498            max_nodes: limits.max_nodes,
499            max_total_scalar_bytes: limits.max_total_scalar_bytes,
500            max_documents: limits.max_documents,
501            max_merge_keys: limits.max_merge_keys,
502            alias_anchor_ratio: limits.alias_anchor_ratio,
503            duplicate_key_policy: DuplicateKeyPolicy::default(),
504            strict_booleans: false,
505            legacy_booleans: false,
506            tag_registry: None,
507            merge_key_policy: MergeKeyPolicy::default(),
508            no_schema: false,
509            legacy_octal_numbers: false,
510            ignore_binary_tag_for_string: false,
511            plain_scalar_strings: false,
512            legacy_sexagesimal: false,
513            #[cfg(feature = "lossless-u64")]
514            lossless_u64_integers: false,
515            leading_zero_integer_strings: false,
516            legacy_binary_numbers: false,
517            float_overflow_strings: false,
518            integer_overflow_errors: false,
519            non_scalar_key_policy: NonScalarKeyPolicy::Stringify,
520            alias_jump_event_factor: limits.alias_jump_event_factor,
521            require_indent: RequireIndent::Unchecked,
522            policies: Vec::new(),
523            #[cfg(feature = "std")]
524            properties: None,
525            #[cfg(feature = "std")]
526            strict_properties: false,
527            #[cfg(feature = "include")]
528            include_resolver: None,
529            #[cfg(feature = "include")]
530            max_include_depth: limits.max_include_depth,
531            #[cfg(feature = "include")]
532            max_include_sources: limits.max_include_sources,
533            #[cfg(feature = "include")]
534            max_total_include_bytes: limits.max_total_include_bytes,
535            stream_tally: None,
536        }
537    }
538}
539
540impl ParserConfig {
541    /// Create a new configuration with default values.
542    ///
543    /// # Examples
544    ///
545    /// ```
546    /// use noyalib::ParserConfig;
547    /// let cfg = ParserConfig::new();
548    /// assert_eq!(cfg.max_depth, 128);
549    /// ```
550    #[must_use]
551    pub fn new() -> Self {
552        Self::default()
553    }
554
555    /// Create a configuration from a stable named profile.
556    ///
557    /// Profiles make trust-boundary configuration explicit and keep callers
558    /// independent from the individual fields that compose each baseline.
559    /// Builder methods can refine the returned configuration.
560    ///
561    /// # Examples
562    ///
563    /// ```
564    /// use noyalib::{ParserConfig, ParserProfile};
565    ///
566    /// let cfg = ParserConfig::profile(ParserProfile::Strict)
567    ///     .max_document_length(32 * 1024);
568    /// assert_eq!(cfg.max_document_length, 32 * 1024);
569    /// ```
570    #[must_use]
571    pub fn profile(profile: ParserProfile) -> Self {
572        match profile {
573            ParserProfile::Standard => Self::default(),
574            ParserProfile::Strict => Self::strict(),
575            ParserProfile::SerdeYaml => Self::serde_yaml_compat(),
576        }
577    }
578
579    /// Create a strict configuration (YAML 1.2 strict) with tighter
580    /// security limits suitable for untrusted input.
581    ///
582    /// # Examples
583    ///
584    /// ```
585    /// use noyalib::ParserConfig;
586    /// let cfg = ParserConfig::strict();
587    /// assert_eq!(cfg.max_depth, 64);
588    /// ```
589    #[must_use]
590    pub fn strict() -> Self {
591        Self {
592            strict_booleans: true,
593            duplicate_key_policy: DuplicateKeyPolicy::Error,
594            require_indent: RequireIndent::Even,
595            #[cfg(feature = "std")]
596            strict_properties: true,
597            ..Self::default().with_limits(ParserLimits::profile(ParserProfile::Strict))
598        }
599    }
600
601    /// Create the configuration the `compat-serde-yaml` shim uses:
602    /// noyalib's defaults with every knob turned to reproduce
603    /// `serde_yaml` 0.9's observable behaviour on the same input —
604    /// the behavioural half of being a drop-in replacement.
605    ///
606    /// Concretely, relative to [`ParserConfig::new`]:
607    ///
608    /// - `<<` merge keys stay ordinary entries whose alias values
609    ///   resolve ([`MergeKeyPolicy::AsOrdinary`]) — serde_yaml
610    ///   never implemented the merge;
611    /// - leading-zero integers (`0123`) resolve as strings and
612    ///   YAML 1.1 binary literals (`0b11`) as integers — libyaml's
613    ///   resolver, spec versions notwithstanding;
614    /// - a literal float overflow (`1e999`) is a string, not
615    ///   infinity;
616    /// - integers past `u64::MAX` error ("JSON number out of
617    ///   range" territory) instead of degrading to `f64`, and
618    ///   (with the `lossless-u64` feature the shim enables)
619    ///   `u64`-range integers keep full precision;
620    /// - a non-scalar mapping key is an error, not a stringified
621    ///   key;
622    /// - transitive alias expansion is budgeted at 100× the
623    ///   document's event count — the exact rule behind
624    ///   serde_yaml's "repetition limit exceeded".
625    ///
626    /// # Examples
627    ///
628    /// ```
629    /// use noyalib::ParserConfig;
630    /// let cfg = ParserConfig::serde_yaml_compat();
631    /// assert!(cfg.leading_zero_integer_strings);
632    /// assert_eq!(cfg.alias_jump_event_factor, Some(100));
633    /// ```
634    #[must_use]
635    pub fn serde_yaml_compat() -> Self {
636        Self {
637            merge_key_policy: MergeKeyPolicy::AsOrdinary,
638            leading_zero_integer_strings: true,
639            legacy_binary_numbers: true,
640            float_overflow_strings: true,
641            integer_overflow_errors: true,
642            non_scalar_key_policy: NonScalarKeyPolicy::Error,
643            #[cfg(feature = "lossless-u64")]
644            lossless_u64_integers: true,
645            ..Self::default().with_limits(ParserLimits::profile(ParserProfile::SerdeYaml))
646        }
647    }
648
649    /// Return the resource budgets currently applied to this configuration.
650    ///
651    /// Semantic settings, policies, property interpolation, tag registries,
652    /// and include resolvers are deliberately excluded.
653    #[must_use]
654    pub fn limits(&self) -> ParserLimits {
655        ParserLimits {
656            max_depth: self.max_depth,
657            max_document_length: self.max_document_length,
658            max_stream_bytes: self.max_stream_bytes,
659            max_alias_expansions: self.max_alias_expansions,
660            max_mapping_keys: self.max_mapping_keys,
661            max_sequence_length: self.max_sequence_length,
662            max_events: self.max_events,
663            max_nodes: self.max_nodes,
664            max_total_scalar_bytes: self.max_total_scalar_bytes,
665            max_documents: self.max_documents,
666            max_merge_keys: self.max_merge_keys,
667            alias_anchor_ratio: self.alias_anchor_ratio,
668            alias_jump_event_factor: self.alias_jump_event_factor,
669            #[cfg(feature = "include")]
670            max_include_depth: self.max_include_depth,
671            #[cfg(feature = "include")]
672            max_include_sources: self.max_include_sources,
673            #[cfg(feature = "include")]
674            max_total_include_bytes: self.max_total_include_bytes,
675        }
676    }
677
678    /// Replace every resource budget while preserving semantic settings and
679    /// installed integrations.
680    ///
681    /// # Examples
682    ///
683    /// ```
684    /// use noyalib::{ParserConfig, ParserLimits, YamlVersion};
685    ///
686    /// let cfg = ParserConfig::new()
687    ///     .version(YamlVersion::V1_1)
688    ///     .with_limits(ParserLimits::strict());
689    /// assert_eq!(cfg.yaml_version, YamlVersion::V1_1);
690    /// assert_eq!(cfg.max_depth, 64);
691    /// ```
692    #[must_use]
693    pub fn with_limits(mut self, limits: ParserLimits) -> Self {
694        self.max_depth = limits.max_depth;
695        self.max_document_length = limits.max_document_length;
696        self.max_stream_bytes = limits.max_stream_bytes;
697        self.max_alias_expansions = limits.max_alias_expansions;
698        self.max_mapping_keys = limits.max_mapping_keys;
699        self.max_sequence_length = limits.max_sequence_length;
700        self.max_events = limits.max_events;
701        self.max_nodes = limits.max_nodes;
702        self.max_total_scalar_bytes = limits.max_total_scalar_bytes;
703        self.max_documents = limits.max_documents;
704        self.max_merge_keys = limits.max_merge_keys;
705        self.alias_anchor_ratio = limits.alias_anchor_ratio;
706        self.alias_jump_event_factor = limits.alias_jump_event_factor;
707        #[cfg(feature = "include")]
708        {
709            self.max_include_depth = limits.max_include_depth;
710            self.max_include_sources = limits.max_include_sources;
711            self.max_total_include_bytes = limits.max_total_include_bytes;
712        }
713        self
714    }
715
716    /// Install a `${KEY}` substitution table consulted after
717    /// parsing.
718    ///
719    /// Each scalar in the resulting [`Value`](crate::Value) tree is walked and
720    /// any `${name}` placeholder is replaced with the property of
721    /// that name. Pairs with [`Self::strict_properties`] to choose
722    /// between erroring or silently empty-substituting on unknown
723    /// keys, and with `${name:-default}` syntax for inline
724    /// defaults.
725    ///
726    /// # Examples
727    ///
728    /// ```
729    /// use noyalib::{from_str_with_config, ParserConfig, Value};
730    /// use std::collections::HashMap;
731    /// use std::sync::Arc;
732    ///
733    /// let mut props = HashMap::new();
734    /// props.insert("HOST".to_string(), "localhost".to_string());
735    /// let cfg = ParserConfig::new().properties(Arc::new(props));
736    /// let v: Value = from_str_with_config("url: http://${HOST}/", &cfg).unwrap();
737    /// assert_eq!(v["url"].as_str(), Some("http://localhost/"));
738    /// ```
739    #[cfg(feature = "std")]
740    #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
741    #[must_use]
742    pub fn properties(
743        mut self,
744        properties: Arc<std::collections::HashMap<String, String>>,
745    ) -> Self {
746        self.properties = Some(properties);
747        self
748    }
749
750    /// Toggle strict-mode placeholder resolution.
751    ///
752    /// When `true`, an unknown `${name}` (no map entry, no
753    /// `:-default` fallback) aborts the parse. When `false`
754    /// (default), unknown placeholders are replaced with the empty
755    /// string — useful for environment-style configs where missing
756    /// variables should silently degrade.
757    ///
758    /// # Examples
759    ///
760    /// ```
761    /// use noyalib::{from_str_with_config, ParserConfig, Value};
762    /// use std::collections::HashMap;
763    /// use std::sync::Arc;
764    ///
765    /// let cfg = ParserConfig::new()
766    ///     .properties(Arc::new(HashMap::new()))
767    ///     .strict_properties(true);
768    /// let res: Result<Value, _> = from_str_with_config("x: ${MISSING}", &cfg);
769    /// assert!(res.is_err());
770    /// ```
771    #[cfg(feature = "std")]
772    #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
773    #[must_use]
774    pub fn strict_properties(mut self, strict: bool) -> Self {
775        self.strict_properties = strict;
776        self
777    }
778
779    /// Install an `!include` directive resolver.
780    ///
781    /// Each `Value::Tagged(!include, scalar_spec)` node in the
782    /// parsed tree is replaced with the resolver's output. The
783    /// resolver is consulted with an `IncludeRequest` carrying
784    /// the verbatim spec text, a stable source-id, and the
785    /// current recursion depth.
786    ///
787    /// Pair with [`Self::max_include_depth`] to bound the
788    /// recursion ceiling. Cycle detection (A includes B includes
789    /// A) runs independently using a per-walk visited set.
790    ///
791    /// # Examples
792    ///
793    /// ```
794    /// use noyalib::include::{IncludeRequest, IncludeResolver, InputSource};
795    /// use noyalib::{ParserConfig, Result};
796    ///
797    /// let resolver = IncludeResolver::new(|req: IncludeRequest<'_>| -> Result<InputSource> {
798    ///     // For an in-memory test, fabricate a YAML payload
799    ///     // keyed on the spec.
800    ///     Ok(InputSource::new(req.spec, format!("name: {}\n", req.spec)))
801    /// });
802    /// let cfg = ParserConfig::new().include_resolver(resolver);
803    /// # let _ = cfg;
804    /// ```
805    #[cfg(feature = "include")]
806    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
807    #[must_use]
808    pub fn include_resolver(mut self, resolver: crate::include::IncludeResolver) -> Self {
809        self.include_resolver = Some(resolver);
810        self
811    }
812
813    /// Maximum `!include` recursion depth.
814    ///
815    /// Default 24 (8 in [`Self::strict()`]). Each nested
816    /// `!include` increments the depth; once the limit is
817    /// reached, the parser aborts with
818    /// `Error::RecursionLimitExceeded`. The cap is independent
819    /// of [`Self::max_depth`] (which bounds *YAML structural*
820    /// nesting) and of the per-walk cycle-detection set (which
821    /// catches A→B→A regardless of depth).
822    #[cfg(feature = "include")]
823    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
824    #[must_use]
825    pub fn max_include_depth(mut self, depth: usize) -> Self {
826        self.max_include_depth = depth;
827        self
828    }
829
830    /// Set the maximum number of sources resolved by one include walk.
831    #[cfg(feature = "include")]
832    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
833    #[must_use]
834    pub fn max_include_sources(mut self, max: usize) -> Self {
835        self.max_include_sources = max;
836        self
837    }
838
839    /// Set the cumulative byte budget for sources returned by include resolvers.
840    #[cfg(feature = "include")]
841    #[cfg_attr(docsrs, doc(cfg(feature = "include")))]
842    #[must_use]
843    pub fn max_total_include_bytes(mut self, max: usize) -> Self {
844        self.max_total_include_bytes = max;
845        self
846    }
847
848    /// Select the YAML specification version the resolver should
849    /// honour.
850    ///
851    /// Selecting [`YamlVersion::V1_1`] is a *preset* over the three
852    /// `legacy_*` flags — equivalent to:
853    ///
854    /// ```text
855    /// cfg.legacy_booleans      = true;  // yes / no / on / off
856    /// cfg.legacy_octal_numbers = true;  // 0644 → octal
857    /// cfg.legacy_sexagesimal   = true;  // 60:00 → 3600
858    /// ```
859    ///
860    /// Selecting [`YamlVersion::V1_2`] resets those three flags to
861    /// `false` so callers can revert to strict 1.2 mode without
862    /// re-creating the config from scratch. Other fields (limits,
863    /// policies, merge-key behaviour) are unaffected.
864    ///
865    /// Fine-grained overrides (e.g. "1.1 booleans but reject octal
866    /// `0644`") work as expected: call `version` first, then flip
867    /// individual flags.
868    ///
869    /// # Examples
870    ///
871    /// ```
872    /// use noyalib::{from_str_with_config, ParserConfig, Value, YamlVersion};
873    ///
874    /// let cfg = ParserConfig::new().version(YamlVersion::V1_1);
875    /// // YAML 1.1 booleans
876    /// let v: Value = from_str_with_config("on", &cfg).unwrap();
877    /// assert_eq!(v, Value::Bool(true));
878    /// // YAML 1.1 octal
879    /// let v: Value = from_str_with_config("0644", &cfg).unwrap();
880    /// assert_eq!(v, Value::from(420_i64));
881    /// // YAML 1.1 sexagesimal
882    /// let v: Value = from_str_with_config("1:30", &cfg).unwrap();
883    /// assert_eq!(v, Value::from(90_i64));
884    /// ```
885    #[must_use]
886    pub fn version(mut self, version: YamlVersion) -> Self {
887        self.yaml_version = version;
888        match version {
889            YamlVersion::V1_1 => {
890                self.legacy_booleans = true;
891                self.legacy_octal_numbers = true;
892                self.legacy_sexagesimal = true;
893            }
894            YamlVersion::V1_2 => {
895                self.legacy_booleans = false;
896                self.legacy_octal_numbers = false;
897                self.legacy_sexagesimal = false;
898            }
899        }
900        self
901    }
902
903    /// Register a [`Policy`](crate::policy::Policy) to enforce
904    /// during parsing.
905    ///
906    /// Multiple policies may be registered; they all run in
907    /// registration order, and the first error short-circuits the
908    /// parse. When any policy is present the streaming fast-path
909    /// is bypassed so the policy contract is enforced uniformly.
910    ///
911    /// # Examples
912    ///
913    /// ```
914    /// use noyalib::{from_str_with_config, ParserConfig, Value};
915    /// use noyalib::policy::DenyAnchors;
916    ///
917    /// let cfg = ParserConfig::new().with_policy(DenyAnchors);
918    /// let res: Result<Value, _> =
919    ///     from_str_with_config("a: &x 1\nb: *x\n", &cfg);
920    /// assert!(res.is_err());
921    /// ```
922    #[must_use]
923    pub fn with_policy<P>(mut self, policy: P) -> Self
924    where
925        P: crate::policy::Policy + 'static,
926    {
927        self.policies.push(Arc::new(policy));
928        self
929    }
930
931    /// Set the maximum recursion depth.
932    ///
933    /// # Examples
934    ///
935    /// ```
936    /// use noyalib::ParserConfig;
937    /// let cfg = ParserConfig::new().max_depth(32);
938    /// assert_eq!(cfg.max_depth, 32);
939    /// ```
940    #[must_use]
941    pub fn max_depth(mut self, depth: usize) -> Self {
942        self.max_depth = depth;
943        self
944    }
945
946    /// Set the maximum document length.
947    ///
948    /// # Examples
949    ///
950    /// ```
951    /// use noyalib::ParserConfig;
952    /// let cfg = ParserConfig::new().max_document_length(1024);
953    /// assert_eq!(cfg.max_document_length, 1024);
954    /// ```
955    #[must_use]
956    pub fn max_document_length(mut self, len: usize) -> Self {
957        self.max_document_length = len;
958        self
959    }
960
961    /// Set the maximum alias expansions.
962    ///
963    /// # Examples
964    ///
965    /// ```
966    /// use noyalib::ParserConfig;
967    /// let cfg = ParserConfig::new().max_alias_expansions(50);
968    /// assert_eq!(cfg.max_alias_expansions, 50);
969    /// ```
970    #[must_use]
971    pub fn max_alias_expansions(mut self, expansions: usize) -> Self {
972        self.max_alias_expansions = expansions;
973        self
974    }
975
976    /// Set the maximum number of mapping keys.
977    ///
978    /// # Examples
979    ///
980    /// ```
981    /// use noyalib::ParserConfig;
982    /// let cfg = ParserConfig::new().max_mapping_keys(100);
983    /// assert_eq!(cfg.max_mapping_keys, 100);
984    /// ```
985    #[must_use]
986    pub fn max_mapping_keys(mut self, max: usize) -> Self {
987        self.max_mapping_keys = max;
988        self
989    }
990
991    /// Set the maximum sequence length.
992    ///
993    /// # Examples
994    ///
995    /// ```
996    /// use noyalib::ParserConfig;
997    /// let cfg = ParserConfig::new().max_sequence_length(100);
998    /// assert_eq!(cfg.max_sequence_length, 100);
999    /// ```
1000    #[must_use]
1001    pub fn max_sequence_length(mut self, max: usize) -> Self {
1002        self.max_sequence_length = max;
1003        self
1004    }
1005
1006    /// Set the maximum total parser-event budget.
1007    ///
1008    /// # Examples
1009    ///
1010    /// ```
1011    /// use noyalib::ParserConfig;
1012    /// let cfg = ParserConfig::new().max_events(50_000);
1013    /// assert_eq!(cfg.max_events, 50_000);
1014    /// ```
1015    #[must_use]
1016    pub fn max_events(mut self, max: usize) -> Self {
1017        self.max_events = max;
1018        self
1019    }
1020
1021    /// Set the maximum total `Value` node budget.
1022    ///
1023    /// # Examples
1024    ///
1025    /// ```
1026    /// use noyalib::ParserConfig;
1027    /// let cfg = ParserConfig::new().max_nodes(10_000);
1028    /// assert_eq!(cfg.max_nodes, 10_000);
1029    /// ```
1030    #[must_use]
1031    pub fn max_nodes(mut self, max: usize) -> Self {
1032        self.max_nodes = max;
1033        self
1034    }
1035
1036    /// Set the maximum cumulative scalar-byte budget.
1037    ///
1038    /// Distinct from [`Self::max_document_length`] — this caps
1039    /// scalar bytes after alias expansion.
1040    ///
1041    /// # Examples
1042    ///
1043    /// ```
1044    /// use noyalib::ParserConfig;
1045    /// let cfg = ParserConfig::new().max_total_scalar_bytes(8 * 1024 * 1024);
1046    /// assert_eq!(cfg.max_total_scalar_bytes, 8 * 1024 * 1024);
1047    /// ```
1048    #[must_use]
1049    pub fn max_total_scalar_bytes(mut self, max: usize) -> Self {
1050        self.max_total_scalar_bytes = max;
1051        self
1052    }
1053
1054    /// Set the maximum document count for multi-document streams.
1055    ///
1056    /// # Examples
1057    ///
1058    /// ```
1059    /// use noyalib::ParserConfig;
1060    /// let cfg = ParserConfig::new().max_documents(64);
1061    /// assert_eq!(cfg.max_documents, 64);
1062    /// ```
1063    #[must_use]
1064    pub fn max_documents(mut self, max: usize) -> Self {
1065        self.max_documents = max;
1066        self
1067    }
1068
1069    /// Set the maximum total byte length of a multi-document stream.
1070    ///
1071    /// # Examples
1072    ///
1073    /// ```
1074    /// use noyalib::ParserConfig;
1075    /// let cfg = ParserConfig::new().max_stream_bytes(8 * 1024 * 1024);
1076    /// assert_eq!(cfg.max_stream_bytes, 8 * 1024 * 1024);
1077    /// ```
1078    #[must_use]
1079    pub fn max_stream_bytes(mut self, max: usize) -> Self {
1080        self.max_stream_bytes = max;
1081        self
1082    }
1083
1084    /// Set the maximum merge-key count budget.
1085    ///
1086    /// # Examples
1087    ///
1088    /// ```
1089    /// use noyalib::ParserConfig;
1090    /// let cfg = ParserConfig::new().max_merge_keys(1_000);
1091    /// assert_eq!(cfg.max_merge_keys, 1_000);
1092    /// ```
1093    #[must_use]
1094    pub fn max_merge_keys(mut self, max: usize) -> Self {
1095        self.max_merge_keys = max;
1096        self
1097    }
1098
1099    /// Set the indentation-validation mode.
1100    ///
1101    /// # Examples
1102    ///
1103    /// ```
1104    /// use noyalib::{ParserConfig, RequireIndent};
1105    /// let cfg = ParserConfig::new().require_indent(RequireIndent::Even);
1106    /// assert_eq!(cfg.require_indent, RequireIndent::Even);
1107    /// ```
1108    #[must_use]
1109    pub fn require_indent(mut self, mode: RequireIndent) -> Self {
1110        self.require_indent = mode;
1111        self
1112    }
1113
1114    /// Set the alias-to-anchor ratio heuristic.
1115    ///
1116    /// Pass `Some(ratio)` to enable the billion-laughs guard,
1117    /// `None` to disable.
1118    ///
1119    /// # Examples
1120    ///
1121    /// ```
1122    /// use noyalib::ParserConfig;
1123    /// let cfg = ParserConfig::new().alias_anchor_ratio(Some(20.0));
1124    /// assert_eq!(cfg.alias_anchor_ratio, Some(20.0));
1125    /// ```
1126    #[must_use]
1127    pub fn alias_anchor_ratio(mut self, ratio: Option<f64>) -> Self {
1128        self.alias_anchor_ratio = ratio;
1129        self
1130    }
1131
1132    /// Set the duplicate key policy.
1133    ///
1134    /// # Examples
1135    ///
1136    /// ```
1137    /// use noyalib::{DuplicateKeyPolicy, ParserConfig};
1138    /// let cfg = ParserConfig::new().duplicate_key_policy(DuplicateKeyPolicy::Error);
1139    /// assert_eq!(cfg.duplicate_key_policy, DuplicateKeyPolicy::Error);
1140    /// ```
1141    #[must_use]
1142    pub fn duplicate_key_policy(mut self, policy: DuplicateKeyPolicy) -> Self {
1143        self.duplicate_key_policy = policy;
1144        self
1145    }
1146
1147    /// Enable or disable strict booleans.
1148    ///
1149    /// # Examples
1150    ///
1151    /// ```
1152    /// use noyalib::ParserConfig;
1153    /// let cfg = ParserConfig::new().strict_booleans(true);
1154    /// assert!(cfg.strict_booleans);
1155    /// ```
1156    #[must_use]
1157    pub fn strict_booleans(mut self, strict: bool) -> Self {
1158        self.strict_booleans = strict;
1159        self
1160    }
1161
1162    /// Enable or disable legacy booleans.
1163    ///
1164    /// # Examples
1165    ///
1166    /// ```
1167    /// use noyalib::ParserConfig;
1168    /// let cfg = ParserConfig::new().legacy_booleans(true);
1169    /// assert!(cfg.legacy_booleans);
1170    /// ```
1171    #[must_use]
1172    pub fn legacy_booleans(mut self, legacy: bool) -> Self {
1173        self.legacy_booleans = legacy;
1174        self
1175    }
1176
1177    /// Attach a [`TagRegistry`](crate::TagRegistry) so the streaming
1178    /// deserializer strips listed custom tags instead of routing them
1179    /// through the AST.
1180    ///
1181    /// See the [`tag_registry`](crate::tag_registry) module
1182    /// documentation for when to use this versus `#[serde(rename)]`.
1183    ///
1184    /// # Examples
1185    ///
1186    /// ```
1187    /// use noyalib::{ParserConfig, TagRegistry};
1188    /// use std::sync::Arc;
1189    /// let reg = Arc::new(TagRegistry::new().with("!Celsius"));
1190    /// let cfg = ParserConfig::new().tag_registry(Arc::clone(&reg));
1191    /// assert!(cfg.tag_registry.is_some());
1192    /// ```
1193    #[must_use]
1194    pub fn tag_registry(mut self, registry: Arc<crate::TagRegistry>) -> Self {
1195        self.tag_registry = Some(registry);
1196        self
1197    }
1198
1199    /// Set the policy for handling the YAML merge key (`<<`).
1200    ///
1201    /// # Examples
1202    ///
1203    /// ```
1204    /// use noyalib::{MergeKeyPolicy, ParserConfig};
1205    /// let cfg = ParserConfig::new().merge_key_policy(MergeKeyPolicy::AsOrdinary);
1206    /// assert_eq!(cfg.merge_key_policy, MergeKeyPolicy::AsOrdinary);
1207    /// ```
1208    #[must_use]
1209    pub fn merge_key_policy(mut self, policy: MergeKeyPolicy) -> Self {
1210        self.merge_key_policy = policy;
1211        self
1212    }
1213
1214    /// Toggle schema-free plain-scalar resolution. When `true`,
1215    /// every plain scalar becomes a string regardless of whether
1216    /// it would normally resolve to `null`, `bool`, integer, or
1217    /// float.
1218    ///
1219    /// # Examples
1220    ///
1221    /// ```
1222    /// use noyalib::ParserConfig;
1223    /// let cfg = ParserConfig::new().no_schema(true);
1224    /// assert!(cfg.no_schema);
1225    /// ```
1226    #[must_use]
1227    pub fn no_schema(mut self, no_schema: bool) -> Self {
1228        self.no_schema = no_schema;
1229        self
1230    }
1231
1232    /// Toggle YAML 1.1-style bare `0`-prefix octal parsing
1233    /// (e.g. `0644` → 420). Off by default; YAML 1.2 requires the
1234    /// `0o` prefix.
1235    ///
1236    /// # Examples
1237    ///
1238    /// ```
1239    /// use noyalib::ParserConfig;
1240    /// let cfg = ParserConfig::new().legacy_octal_numbers(true);
1241    /// assert!(cfg.legacy_octal_numbers);
1242    /// ```
1243    #[must_use]
1244    pub fn legacy_octal_numbers(mut self, on: bool) -> Self {
1245        self.legacy_octal_numbers = on;
1246        self
1247    }
1248
1249    /// Toggle the migration-helper behaviour where
1250    /// `!!binary "ABCD"` deserializes into a [`String`] target as
1251    /// the literal base64 source string. The canonical bytes
1252    /// path (`Vec<u8>`, `serde_bytes::ByteBuf`) is unaffected —
1253    /// it always decodes the base64 payload.
1254    ///
1255    /// # Examples
1256    ///
1257    /// ```
1258    /// use noyalib::ParserConfig;
1259    /// let cfg = ParserConfig::new().ignore_binary_tag_for_string(true);
1260    /// assert!(cfg.ignore_binary_tag_for_string);
1261    /// ```
1262    #[must_use]
1263    pub fn ignore_binary_tag_for_string(mut self, on: bool) -> Self {
1264        self.ignore_binary_tag_for_string = on;
1265        self
1266    }
1267
1268    /// When enabled, a plain scalar deserializes into a [`String`]
1269    /// (or `char`) target as its source text even where the YAML 1.2
1270    /// schema resolves it to a number, boolean, or null:
1271    /// `password: 123456` gives `"123456"`, `~` gives `"~"`, an empty
1272    /// value gives `""`. Off by default: a `String` field refuses a
1273    /// non-string plain scalar. Quoted scalars are strings either
1274    /// way. Matches what serde_yaml did for typed targets.
1275    ///
1276    /// # Examples
1277    ///
1278    /// ```
1279    /// use noyalib::ParserConfig;
1280    /// let cfg = ParserConfig::new().plain_scalar_strings(true);
1281    /// assert!(cfg.plain_scalar_strings);
1282    /// ```
1283    #[must_use]
1284    pub fn plain_scalar_strings(mut self, on: bool) -> Self {
1285        self.plain_scalar_strings = on;
1286        self
1287    }
1288
1289    /// Toggle YAML 1.1-style sexagesimal number parsing
1290    /// (`60:00` → 3 600). Off by default; YAML 1.2 dropped the
1291    /// sexagesimal schema, so plain `1:30:00` would otherwise
1292    /// surface as a string.
1293    ///
1294    /// # Examples
1295    ///
1296    /// ```
1297    /// use noyalib::ParserConfig;
1298    /// let cfg = ParserConfig::new().legacy_sexagesimal(true);
1299    /// assert!(cfg.legacy_sexagesimal);
1300    /// ```
1301    #[must_use]
1302    pub fn legacy_sexagesimal(mut self, on: bool) -> Self {
1303        self.legacy_sexagesimal = on;
1304        self
1305    }
1306
1307    /// Enable or disable lossless unsigned integer resolution.
1308    ///
1309    /// With the `lossless-u64` feature enabled, setting this to
1310    /// `true` lets YAML integer scalars in `(i64::MAX, u64::MAX]`
1311    /// resolve as `Number::Unsigned` instead of falling through to
1312    /// `Number::Float`.
1313    #[cfg(feature = "lossless-u64")]
1314    #[cfg_attr(docsrs, doc(cfg(feature = "lossless-u64")))]
1315    #[must_use]
1316    pub fn lossless_u64_integers(mut self, on: bool) -> Self {
1317        self.lossless_u64_integers = on;
1318        self
1319    }
1320}
1321
1322/// Policy for handling the YAML merge key (`<<`) during parsing.
1323///
1324/// YAML 1.2 §10.2 defines `<<` as a "merge key" that, when used as
1325/// a mapping key, splices the value's mapping (or sequence of
1326/// mappings) into the enclosing mapping. The variants below let
1327/// callers opt out of that behaviour.
1328///
1329/// # Examples
1330///
1331/// Indentation-validation mode for the YAML scanner.
1332///
1333/// Issue #6 surface — every block-context indent transition is
1334/// classified per the chosen mode. The default
1335/// ([`RequireIndent::Unchecked`]) accepts any well-formed YAML
1336/// indent (the YAML 1.2 spec mandate), which is what every
1337/// other Rust YAML parser does.
1338///
1339/// Stricter modes are useful in pipelines that require uniform
1340/// indentation house style (linters, formatters, reviewer
1341/// gates) — a config file with mixed `2`-space and `4`-space
1342/// indent passes by-spec but fails consistency review.
1343///
1344/// # Variants
1345///
1346/// - [`RequireIndent::Unchecked`] (default) — by-spec mode.
1347/// - [`RequireIndent::Even`] — every indent delta must be even.
1348/// - `RequireIndent::Divisible(N)` — every indent delta must
1349///   be divisible by `N`.
1350/// - `RequireIndent::Uniform(Some(N))` — every indent delta
1351///   must equal `N`. `None` means "auto-detect from the first
1352///   delta and require the rest to match it".
1353///
1354/// # Examples
1355///
1356/// ```
1357/// use noyalib::{ParserConfig, RequireIndent};
1358/// let cfg = ParserConfig::new().require_indent(RequireIndent::Even);
1359/// assert_eq!(cfg.require_indent, RequireIndent::Even);
1360/// ```
1361#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1362#[non_exhaustive]
1363pub enum RequireIndent {
1364    /// Accept any indent transition the YAML 1.2 spec allows.
1365    /// Default.
1366    #[default]
1367    Unchecked,
1368    /// Indent delta must be even (`2`, `4`, `6`, …). The most
1369    /// common house-style.
1370    Even,
1371    /// Indent delta must be divisible by `N`.
1372    Divisible(usize),
1373    /// `Some(N)`: every indent delta must equal `N`.
1374    /// `None`: the first delta sets the standard for the
1375    /// document; subsequent deltas must match it.
1376    Uniform(Option<usize>),
1377}
1378
1379/// ```
1380/// use noyalib::MergeKeyPolicy;
1381/// assert_eq!(MergeKeyPolicy::default(), MergeKeyPolicy::Auto);
1382/// ```
1383#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1384#[non_exhaustive]
1385pub enum MergeKeyPolicy {
1386    /// Apply the YAML 1.2 merge-key semantics — `<<:` keys trigger
1387    /// automatic merge of the value into the enclosing mapping.
1388    /// Default.
1389    ///
1390    /// Only a **plain** `<<` scalar is a merge key. The YAML merge
1391    /// type gives `tag:yaml.org,2002:merge` to a plain `<<`; a quoted
1392    /// `"<<"` and an alias that happens to resolve to the string
1393    /// `<<` both resolve to `tag:yaml.org,2002:str` and stay ordinary
1394    /// keys, whatever this policy is set to.
1395    #[default]
1396    Auto,
1397    /// Treat `<<` as an ordinary string key. The mapping retains a
1398    /// literal `<<` entry whose value is whatever the YAML
1399    /// document supplied. Useful when round-tripping configuration
1400    /// files that happen to contain a `<<` key for non-merge
1401    /// reasons.
1402    AsOrdinary,
1403    /// Reject any document that contains a `<<` key with
1404    /// [`crate::Error::Custom`]. Useful for schema-strict pipelines
1405    /// where merge keys are forbidden.
1406    Error,
1407}
1408
1409/// Policy for handling a non-scalar mapping key (`[a, b]: v`,
1410/// `{k: v}: w`).
1411///
1412/// # Examples
1413///
1414/// ```
1415/// use noyalib::NonScalarKeyPolicy;
1416/// assert_eq!(NonScalarKeyPolicy::default(), NonScalarKeyPolicy::Stringify);
1417/// ```
1418#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1419#[non_exhaustive]
1420pub enum NonScalarKeyPolicy {
1421    /// Convert the key to its deterministic string form
1422    /// (`"[a, b]"`), reporting a `KeyCollision` when two distinct
1423    /// keys stringify identically. Default.
1424    #[default]
1425    Stringify,
1426    /// Abort the parse with
1427    /// [`ErrorKind::NonScalarKey`](crate::ErrorKind) — the
1428    /// behaviour `serde_yaml` 0.9 had when the target was a
1429    /// string-keyed structure ("invalid type: sequence, expected a
1430    /// string key").
1431    Error,
1432}
1433
1434/// Policy for handling duplicate keys in a YAML mapping.
1435///
1436/// # Examples
1437///
1438/// ```
1439/// use noyalib::DuplicateKeyPolicy;
1440/// assert_eq!(DuplicateKeyPolicy::default(), DuplicateKeyPolicy::Last);
1441/// ```
1442#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1443#[non_exhaustive]
1444pub enum DuplicateKeyPolicy {
1445    /// Use the first occurrence of the key; ignore subsequent ones.
1446    First,
1447    /// Use the last occurrence of the key (YAML 1.2 default).
1448    #[default]
1449    Last,
1450    /// Return an error if a duplicate key is encountered.
1451    Error,
1452}