Skip to main content

serde_saphyr/de/
options.rs

1use crate::budget::Budget;
2use crate::indentation::RequireIndent;
3#[cfg(feature = "properties")]
4use std::collections::HashMap;
5#[cfg(feature = "include_fs")]
6use std::io;
7#[cfg(feature = "include_fs")]
8use std::path::Path;
9use std::rc::Rc;
10
11#[cfg(all(feature = "serde_derived_types", feature = "comments"))]
12const fn default_emit_comments() -> bool {
13    true
14}
15
16/// Duplicate key handling policy for mappings.
17///
18/// Except with [`Self::PassThrough`], YAML integer keys are compared by their
19/// parsed integer value, so `0xB` and `11` are duplicates even when the target
20/// key type is `String`. The retained key preserves its original spelling when
21/// deserialized into a string. Merge expansion always uses YAML key identity.
22#[non_exhaustive]
23#[derive(Clone, Copy, Debug)]
24#[cfg_attr(
25    feature = "serde_derived_types",
26    derive(serde::Serialize, serde::Deserialize)
27)]
28#[cfg_attr(feature = "serde_derived_types", serde(rename_all = "snake_case"))]
29pub enum DuplicateKeyPolicy {
30    /// Error out on encountering a duplicate key.
31    Error,
32    /// First key wins: later duplicate pairs are skipped (key+value are consumed and ignored).
33    FirstWins,
34    /// Last key wins: retain the later value. Struct fields are buffered and
35    /// collapsed before Serde sees them. Maps stream until an integer-containing
36    /// key requires numeric comparison, then buffer the remaining entries to
37    /// retain the last key's original spelling and value.
38    LastWins,
39    /// Forward repeated explicit mapping entries to the receiving Serde visitor.
40    ///
41    /// This matches `serde_yaml`'s visitor-driven handling of explicit entries:
42    /// derived structs reject repeated fields, standard maps keep the last value,
43    /// and custom visitors decide whether to reject, replace, or retain duplicates.
44    /// Integer-equivalent keys such as `0xB` and `11` are also forwarded separately.
45    ///
46    /// Merge expansion still follows [`MergeKeyPolicy`] and suppresses overridden
47    /// entries. Repeated keys within a merge source retain the first entry. Use
48    /// [`MergeKeyPolicy::AsOrdinary`] to forward literal `<<` entries as well.
49    ///
50    /// This is a permissive extension: YAML requires unique mapping keys.
51    /// [`Self::Error`] remains the default.
52    PassThrough,
53}
54
55/// Handling of non-finite floats in `deserialize_any`.
56///
57/// By default, non-finite floats are rejected. This policy applies to inferred values,
58/// including Serde's buffering for untagged enums and flattened fields. Direct `f32`/`f64`
59/// deserialization is unaffected.
60#[non_exhaustive]
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62#[cfg_attr(
63    feature = "serde_derived_types",
64    derive(serde::Serialize, serde::Deserialize)
65)]
66#[cfg_attr(feature = "serde_derived_types", serde(rename_all = "snake_case"))]
67pub enum NonFiniteFloatPolicy {
68    // Compatibility sentinel: defer to the deprecated boolean until it is removed,
69    // then use the unconfigured policy (Reject).
70    #[doc(hidden)]
71    #[default]
72    Default,
73    /// Pass NaN and positive/negative infinity to the visitor as `f64` values.
74    ///
75    /// The receiving visitor determines how to represent them. For example,
76    /// `serde_json::Value` converts them to `Null` without returning an error.
77    PassThrough,
78    /// Return [`crate::Error::NonFiniteFloat`] before calling the visitor.
79    Reject,
80    /// Pass canonical strings (`.nan`, `.inf`, `-.inf`) to the visitor.
81    AsString,
82}
83
84/// Syntax and expansion rules for `${NAME}` / `$NAME` property interpolation.
85#[cfg(feature = "properties")]
86#[non_exhaustive]
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88#[cfg_attr(
89    feature = "serde_derived_types",
90    derive(serde::Serialize, serde::Deserialize)
91)]
92#[cfg_attr(feature = "serde_derived_types", serde(rename_all = "snake_case"))]
93pub enum PropertySyntax {
94    /// Only the braced `${NAME}` form is interpolated. Bare `$NAME` stays literal.
95    #[default]
96    Braced,
97
98    /// Both `${NAME}` and the unbraced shorthand `$NAME` are interpolated.
99    /// The unbraced form uses Required semantics (missing values error).
100    BracedOrBare,
101
102    /// Property interpolation compatible with [Docker Compose]'s syntax and value expansion.
103    /// [Docker Compose]: https://docs.docker.com/reference/compose-file/interpolation/
104    DockerCompose,
105
106    /// [`DockerCompose`](Self::DockerCompose) interpolation, except that an expanded value
107    /// can become YAML null:
108    ///
109    /// - A value consisting solely of `$NAME` or `${NAME}` is null when `NAME` is unset.
110    /// - A value whose expansion is exactly `null`, `Null`, `NULL`, or `~` is null.
111    ///
112    /// Everything else keeps the Compose behavior: an explicitly empty property and an empty
113    /// selected default (`${A:-${B}}` with both unset) are empty strings, surrounding text is
114    /// kept (`prefix-$MISSING` is `prefix-`), and numbers and booleans convert as before.
115    ///
116    /// The rules apply to the scalar's text whatever its style, so a quoted `"$NAME"` is null
117    /// as well, while a `|` block keeps its trailing newline and is therefore not a sole
118    /// reference. String-forcing tags (`!!str`, `!`) and other explicit tags keep their
119    /// meaning, and scalars without an expansion are resolved exactly as in other modes.
120    DockerComposeWithNulls,
121}
122
123#[cfg(feature = "properties")]
124impl PropertySyntax {
125    /// Whether this syntax uses Docker Compose interpolation rules.
126    pub(crate) const fn is_docker_compose(self) -> bool {
127        matches!(self, Self::DockerCompose | Self::DockerComposeWithNulls)
128    }
129}
130
131/// Merge key handling policy for YAML mappings.
132#[non_exhaustive]
133#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
134#[cfg_attr(
135    feature = "serde_derived_types",
136    derive(serde::Serialize, serde::Deserialize)
137)]
138#[cfg_attr(feature = "serde_derived_types", serde(rename_all = "snake_case"))]
139pub enum MergeKeyPolicy {
140    /// Expand YAML merge keys (`<<` and explicit `!!merge <<`) into the surrounding mapping,
141    /// as per the YAML 1.1 specification.
142    #[default]
143    Merge,
144
145    /// Treat YAML merge keys (`<<` and explicit `!!merge <<`) as ordinary mapping keys.
146    AsOrdinary,
147
148    /// Error out on encountering a YAML merge key (`<<` or explicit `!!merge <<`), reporting
149    /// the location.
150    Error,
151}
152
153/// Limits applied to alias replay to harden against alias bombs.
154///
155/// These limits also apply while aliases are expanded from anchored `!include` fragments.
156///
157/// Prefer constructing this via the [`alias_limits!`](crate::alias_limits!) macro instead of a
158/// struct literal. This keeps call sites stable if new fields are added in the future.
159///
160/// ```rust
161/// # #[cfg(feature = "deserialize")]
162/// # {
163/// let limits = serde_saphyr::alias_limits! {
164///     max_replay_stack_depth: 32,
165/// };
166///
167/// assert_eq!(limits.max_replay_stack_depth, 32);
168/// # }
169/// ```
170#[non_exhaustive]
171#[derive(Clone, Copy, Debug)]
172#[cfg_attr(
173    feature = "serde_derived_types",
174    derive(serde::Serialize, serde::Deserialize)
175)]
176pub struct AliasLimits {
177    /// Maximum total number of **replayed** events injected from aliases across the entire parse.
178    /// When exceeded, deserialization errors (alias replay limit exceeded).
179    pub max_total_replayed_events: usize,
180    /// Maximum depth of the alias replay stack (nested alias → injected buffer → alias, etc.).
181    pub max_replay_stack_depth: usize,
182    /// Maximum number of times a **single anchor id** may be expanded via alias.
183    /// Use `usize::MAX` for "unlimited".
184    pub max_alias_expansions_per_anchor: usize,
185}
186
187impl Default for AliasLimits {
188    fn default() -> Self {
189        Self {
190            max_total_replayed_events: 1_000_000,
191            max_replay_stack_depth: 64,
192            max_alias_expansions_per_anchor: usize::MAX,
193        }
194    }
195}
196
197/// Parser configuration options.
198///
199/// Use this to configure comment emission, duplicate-key policy, alias-replay limits, and an
200/// optional pre-parse YAML [`Budget`].
201///
202/// Example: parse a small `Config` using custom `Options`.
203///
204/// ```rust
205/// use serde::Deserialize;
206///
207/// use serde_saphyr::options::DuplicateKeyPolicy;
208/// use serde_saphyr::{from_str_with_options, Budget, Options};
209///
210/// #[derive(Deserialize)]
211/// struct Config {
212///     name: String,
213///     enabled: bool,
214///     retries: i32,
215/// }
216///
217/// let yaml = r#"
218/// name: My Application
219/// enabled: true
220/// retries: 5
221/// "#;
222///
223/// let options = serde_saphyr::options! {
224///     budget: serde_saphyr::budget! {
225///         max_documents: 2,
226///     },
227///     duplicate_keys: DuplicateKeyPolicy::LastWins,
228/// };
229///
230/// let cfg: Config = from_str_with_options(yaml, options).unwrap();
231/// assert_eq!(cfg.name, "My Application");
232/// ```
233#[non_exhaustive]
234#[derive(Clone)]
235#[cfg_attr(
236    feature = "serde_derived_types",
237    derive(serde::Serialize, serde::Deserialize)
238)]
239pub struct Options {
240    /// Optional YAML budget enforced during parsing and deserialization, including raw events,
241    /// retained anchor copies, and property interpolation. `None` disables budget enforcement.
242    pub budget: Option<Budget>,
243    /// Optional callback invoked with the final budget report after parsing.
244    /// It is invoked both when parsing is successful and when budget was breached.
245    #[cfg_attr(feature = "serde_derived_types", serde(skip))]
246    pub budget_report: Option<fn(&crate::budget::BudgetReport)>,
247
248    /// Invoked both when parsing is successful and when budget was breached.
249    #[cfg_attr(feature = "serde_derived_types", serde(skip))]
250    pub budget_report_cb: Option<BudgetReportCallback>,
251
252    /// Whether the parser emits comment events for deserialization.
253    ///
254    /// The default is `true`. When this is `false`, comments are still recognized and validated
255    /// as YAML syntax, but their text is not captured and [`crate::Commented`] values receive an
256    /// empty comment string.
257    /// Comment bytes are still consumed and validated, so disabling emission is not an input-size
258    /// or processing-time limit.
259    ///
260    /// Comment-related budget accounting follows emitted, retained parser data. Setting this to
261    /// `false` also:
262    ///
263    /// - disables enforcement of [`Budget::max_total_comment_bytes`];
264    /// - makes [`Budget::max_buffered_comment_events`] ineffective;
265    /// - excludes comments from [`Budget::max_events`]; and
266    /// - keeps [`crate::budget::BudgetReport::total_comment_bytes`] at zero.
267    #[cfg_attr(
268        feature = "serde_derived_types",
269        serde(default = "default_emit_comments")
270    )]
271    #[cfg(feature = "comments")]
272    pub emit_comments: bool,
273
274    /// Enforce YAML indentation rules for flow collections (`[...]` and `{...}`).
275    ///
276    /// When enabled, flow entries, delimiters, and scalar continuation lines must be
277    /// indented beyond the enclosing block collection. Applies to included YAML as well.
278    /// Default: false (accept under-indented flow collections for compatibility).
279    ///
280    /// Accepted with `strict_indentation: true`: entries and closing delimiters are
281    /// indented beyond the enclosing block mapping.
282    ///
283    /// ```yaml
284    /// sequence: [
285    ///   first,
286    ///   second
287    ///  ]
288    /// mapping: {
289    ///   first: one,
290    ///   second: two
291    ///  }
292    /// ```
293    ///
294    /// Accepted with `strict_indentation: false`, but rejected with `true` because
295    /// the closing delimiters are at the same indentation as the enclosing block mapping:
296    ///
297    /// ```yaml
298    /// sequence: [
299    ///   first,
300    ///   second
301    /// ]
302    /// mapping: {
303    ///   first: one,
304    ///   second: two
305    /// }
306    /// ```
307    ///
308    /// Relaxed mode also accepts under-indented entries, not just closing delimiters.
309    /// This is accepted with `strict_indentation: false`, but rejected with `true`
310    /// at the unindented `"value"` entry:
311    ///
312    /// ```yaml
313    /// key: [
314    /// "value",
315    /// ]
316    /// ```
317    #[cfg_attr(feature = "serde_derived_types", serde(default))]
318    pub strict_indentation: bool,
319
320    /// Policy for duplicate keys.
321    pub duplicate_keys: DuplicateKeyPolicy,
322    /// Policy for YAML merge keys (`<<` and explicit `!!merge <<`).
323    ///
324    /// [`MergeKeyPolicy::Merge`] expands merge keys and counts them against
325    /// [`Budget::max_merge_keys`]. [`MergeKeyPolicy::AsOrdinary`] accepts `<<`
326    /// as a regular key. [`MergeKeyPolicy::Error`] rejects merge keys.
327    ///
328    /// Default: [`MergeKeyPolicy::Merge`].
329    #[cfg_attr(feature = "serde_derived_types", serde(default))]
330    pub merge_keys: MergeKeyPolicy,
331    /// Limits for alias replay to harden against alias bombs.
332    pub alias_limits: AliasLimits,
333    /// Enable legacy octal parsing where values starting with `0` are treated as base-8.
334    /// They are deprecated in YAML 1.2. Default: false.
335    pub legacy_octal_numbers: bool,
336    /// Preserve plain decimal integer spellings with redundant leading zeros as
337    /// strings during implicit type inference (`deserialize_any`).
338    ///
339    /// Examples include `0755`, `-007`, and `+00`. Floating-point spellings such as
340    /// `0.5`, `00.5`, and `01e2`, and explicit radix prefixes, are unaffected.
341    /// This supports compatibility with `serde_yaml` when reading untyped values.
342    /// Default: false.
343    ///
344    /// Applies to untyped values and Serde's buffering for untagged enums and
345    /// flattened fields. Takes precedence over [`Self::legacy_octal_numbers`]
346    /// during inference. Direct typed deserialization, explicit `!!int`/`!!float`
347    /// tags, and mapping-key equality retain their existing behavior.
348    #[cfg_attr(feature = "serde_derived_types", serde(default))]
349    pub leading_zero_numbers_as_strings: bool,
350    /// Preserve plain scalars containing underscores as strings during implicit
351    /// type inference (`deserialize_any`), including numeric spellings such as
352    /// `1_000`, `0xFF_FF`, and `0b10_01`.
353    ///
354    /// This supports compatibility with `serde_yaml` when reading untyped values.
355    /// It also applies to untagged enums and flattened fields, and takes precedence
356    /// over [`Self::legacy_octal_numbers`] during inference. Direct typed
357    /// deserialization, explicit `!!int`/`!!float` tags, and mapping-key equality
358    /// retain their existing behavior. Default: false.
359    #[cfg_attr(feature = "serde_derived_types", serde(default))]
360    pub underscored_numbers_as_strings: bool,
361    /// If true, accept only YAML 1.2 Core boolean spellings:
362    /// `true`, `True`, `TRUE`, `false`, `False`, and `FALSE`.
363    /// Other spellings, including YAML 1.1 forms such as `yes`/`no`/`on`/`off`,
364    /// are rejected for boolean targets and are not inferred as booleans.
365    /// Default: false (accept YAML 1.1 boolean forms).
366    pub strict_booleans: bool,
367    /// When a field marked with the `!!binary` tag is deserialized into a `String`,
368    /// `serde-saphyr` normally expects the value to be base64-encoded UTF-8.
369    /// If you want to treat the value as a plain string and ignore the `!!binary` tag,
370    /// set this to `true` (the default is `false`).
371    pub ignore_binary_tag_for_string: bool,
372    /// Activates YAML conventions common in robotics community. These extensions support
373    /// conversion functions (deg, rad) and simple mathematical expressions such as deg(180),
374    /// rad(pi), 1 + 2*(3 - 4/5), or rad(pi/2). `robotics` feature must also be enabled.
375    /// The `!degrees`, `!radians`, and `!time` tags select floating-point conversions.
376    /// `!time` represents a duration in seconds; the standard `!!timestamp` tag does not.
377    pub angle_conversions: bool,
378    /// If true, values that can be parsed as booleans or numbers are rejected as
379    /// unquoted strings. This flag is intended for teams that want to enforce
380    /// compatibility with YAML parsers that infer types from unquoted values,
381    /// requiring such strings to be explicitly quoted.
382    /// The default is false (a number or boolean will be stored in the string
383    /// field exactly as provided, without quoting).
384    pub no_schema: bool,
385
386    /// Reject explicitly specified YAML tags that `serde-saphyr` does not recognize.
387    ///
388    /// This includes application-specific local and global tags. YAML 1.1 `!!merge`
389    /// and `!!value` tags are accepted only on their exact scalar mapping keys, `<<`
390    /// and `=` respectively. Known scalar, sequence, and mapping tags are accepted
391    /// only on matching node kinds; this node-kind requirement is enforced regardless
392    /// of this option. Robotics-only `!degrees`, `!radians`, and `!time` tags are
393    /// supported only when the `robotics` crate feature and
394    /// [`Self::angle_conversions`] are both enabled. The `!include` tag is supported
395    /// only when the `include` crate feature is enabled and an include resolver is
396    /// configured.
397    ///
398    /// Default: false (preserve unsupported tags for compatibility with tagged enums
399    /// and otherwise ignore them where possible).
400    #[cfg_attr(feature = "serde_derived_types", serde(default))]
401    pub reject_unsupported_tags: bool,
402
403    /// Reject explicit tags on struct field names, except standard string tags.
404    ///
405    /// A derived struct normally matches each mapping key by its text alone. A
406    /// custom tag on that key can therefore be discarded without the struct ever
407    /// seeing it. Such a tag has no effect on field selection and may be a value
408    /// annotation accidentally placed on the field name. Enable this option to
409    /// report [`crate::Error::TaggedStructFieldName`] instead of silently accepting it.
410    ///
411    /// For example, an application can inspect a value's `!secret` tag by reading
412    /// that value as [`crate::Tagged<T>`]:
413    ///
414    /// ```yaml
415    /// password: !secret API_TOKEN
416    /// ```
417    ///
418    /// This misplaced annotation is rejected when the option is enabled:
419    ///
420    /// ```yaml
421    /// !secret password: API_TOKEN
422    /// ```
423    ///
424    /// Untagged and quoted field names remain accepted. Explicit standard string tags
425    /// (`!!str`, including equivalent verbatim or directive-expanded forms) and
426    /// the non-specific `!` tag are also accepted. Field names are the external
427    /// strings used by Serde: `!!str 0.0: Alice` can populate a Rust field marked
428    /// `#[serde(rename = "0.0")]`. Here `!!str` explicitly gives `0.0` string
429    /// semantics, and Serde's rename handles the external field name.
430    /// The exception uses the resolved tag identity; a local `!str` tag is still
431    /// rejected, including when a directive rebinds `!!` to an application namespace.
432    ///
433    /// This option applies only to struct mappings entered through Serde's
434    /// `deserialize_struct`, including externally tagged struct enum variants.
435    /// It checks every key in such a mapping, including unknown keys that Serde
436    /// would otherwise ignore without `#[serde(deny_unknown_fields)]`.
437    /// It also checks field names replayed through aliases and expanded merges.
438    /// Keys are checked before duplicate-key or merge precedence filtering, even
439    /// when their values would otherwise be discarded.
440    /// Ordinary map keys, including string, numeric, and enum keys, and tags on
441    /// values retain their existing behavior. Unlike
442    /// [`Self::reject_unsupported_tags`], it does not reject application tags
443    /// throughout the document.
444    /// Tags consumed by enabled preprocessing, such as `!include`, retain that
445    /// behavior before this check.
446    ///
447    /// Serde can match fields without calling `deserialize_struct`: structs using
448    /// `#[serde(flatten)]` enter through `deserialize_map`, while internally tagged
449    /// and untagged enums buffer their fields first. Adjacently tagged enums also
450    /// buffer content that precedes the discriminator. Fields handled by those
451    /// paths, including structs nested in their buffers, are not covered by this
452    /// option; it is not a document-wide guarantee against tagged field names.
453    ///
454    /// Default: false (retain existing field-name handling).
455    #[cfg_attr(feature = "serde_derived_types", serde(default))]
456    pub reject_tagged_field_names: bool,
457
458    /// Handling of NaN, positive/negative infinity, and decimal literals that overflow
459    /// `f64` (e.g. `1e999`) in `deserialize_any`.
460    ///
461    /// If left at its default, this uses [`Self::reject_non_finite_typeless_float`],
462    /// preserving rejection by default and string conversion when that legacy flag is
463    /// false. Explicit [`NonFiniteFloatPolicy::PassThrough`], [`NonFiniteFloatPolicy::Reject`],
464    /// and [`NonFiniteFloatPolicy::AsString`] values override the legacy flag.
465    ///
466    /// `PassThrough` lets float-capable visitors preserve non-finite values, including
467    /// untagged enums and flattened float fields. `serde_json::Value` instead converts
468    /// them to `Null` without an error. `Reject` prevents that loss; `AsString` passes
469    /// canonical strings (`.nan`, `.inf`, `-.inf`) to the visitor.
470    ///
471    /// Untagged quoted scalars and `!!str` scalars remain strings under every policy.
472    /// Direct `f32`/`f64` targets are unaffected: YAML non-finite spellings remain
473    /// accepted, while overflowing decimal/exponential literals remain invalid.
474    #[cfg_attr(feature = "serde_derived_types", serde(default))]
475    pub non_finite_float_policy: NonFiniteFloatPolicy,
476
477    /// Legacy handling of non-finite floats in `deserialize_any`.
478    ///
479    /// When [`Self::non_finite_float_policy`] is left at its default, true (the default)
480    /// rejects non-finite floats and false converts them to canonical strings.
481    /// An explicit policy overrides this flag.
482    ///
483    /// [`Options::default()`] sets this to true. When deserializing options with
484    /// `serde_derived_types`, an omitted flag defaults to false for compatibility
485    /// with 1.3.0. If the policy is also omitted, non-finite values become strings.
486    ///
487    /// Use [`Self::non_finite_float_policy`] with [`NonFiniteFloatPolicy::Reject`] instead
488    /// of true, or [`NonFiniteFloatPolicy::AsString`] instead of false.
489    #[deprecated(since = "2.0.0", note = "use non_finite_float_policy instead")]
490    #[cfg_attr(feature = "serde_derived_types", serde(default))]
491    pub reject_non_finite_typeless_float: bool,
492
493    /// If true (default), public APIs that have access to the original YAML input
494    /// will wrap returned errors with a snippet wrapper, enabling rustc-like snippet
495    /// rendering when a location is available.
496    pub with_snippet: bool,
497
498    /// Horizontal crop radius (in character columns) when rendering snippet diagnostics.
499    ///
500    /// The renderer crops all displayed lines (including the context lines) to the same
501    /// column window around the reported error column, so they stay vertically aligned.
502    ///
503    /// If set to `0`, snippet wrapping is disabled (the original, unwrapped error is returned).
504    pub crop_radius: usize,
505
506    /// Indentation requirement for the parsed document.
507    pub require_indent: RequireIndent,
508
509    /// Optional include resolver callback.
510    ///
511    /// When provided, it can push parsers onto the internal parser stack to resolve `!include`
512    ///-like constructs.
513    #[cfg(feature = "include")]
514    #[cfg_attr(feature = "serde_derived_types", serde(skip))]
515    pub include_resolver: Option<IncludeResolverCallback>,
516
517    /// A map of properties to substitute in scalar values.
518    /// Used for docker-compose-style interpolation like `${VAR}`.
519    #[cfg(feature = "properties")]
520    #[cfg_attr(feature = "serde_derived_types", serde(skip))]
521    pub property_map: Option<Rc<HashMap<String, String>>>,
522
523    /// Which property-interpolation syntaxes are recognized.
524    /// Defaults to [`PropertySyntax::Braced`] (only `${NAME}`).
525    #[cfg(feature = "properties")]
526    #[cfg_attr(feature = "serde_derived_types", serde(default))]
527    pub property_syntax: PropertySyntax,
528}
529
530#[cfg(feature = "include")]
531pub type IncludeResolverCallback = Rc<
532    std::cell::RefCell<
533        dyn for<'res> FnMut(
534                crate::input_source::IncludeRequest<'res>,
535            )
536                -> Result<crate::ResolvedInclude, crate::IncludeResolveError>
537            + 'static,
538    >,
539>;
540
541pub type BudgetReportCallback =
542    Rc<std::cell::RefCell<dyn FnMut(crate::budget::BudgetReport) + 'static>>;
543
544impl Options {
545    pub(crate) fn parser_options(&self) -> granit_parser::Options {
546        let default_budget = Budget::default();
547        let budget = self.budget.as_ref().unwrap_or(&default_budget);
548        let mut parser_options = budget.parser_options();
549        #[cfg(feature = "comments")]
550        {
551            parser_options.emit_comments = self.emit_comments;
552        }
553        parser_options.strict_indentation = self.strict_indentation;
554        parser_options
555    }
556
557    pub(crate) fn validate(&self) -> Result<(), crate::de_error::Error> {
558        self.require_indent.validate()
559    }
560
561    /// Registers a budget-report callback. Any closure can be used,  including ones that
562    /// capture state from the surrounding scope.
563    ///
564    /// The callback is invoked with the final [`crate::budget::BudgetReport`] after parsing
565    /// completes, both on success and when the budget is breached.
566    ///
567    /// ```rust
568    /// use serde_saphyr::options;
569    /// use serde_saphyr::budget::BudgetReport;
570    ///
571    /// let options = options! {}.with_budget_report(|report: BudgetReport| {
572    ///     // e.g. update your state / emit metrics / log the report
573    ///     let _ = report;
574    /// });
575    /// ```
576    #[must_use]
577    pub fn with_budget_report<F>(mut self, cb: F) -> Self
578    where
579        F: FnMut(crate::budget::BudgetReport) + 'static,
580    {
581        self.budget_report_cb = Some(Rc::new(std::cell::RefCell::new(cb)));
582        self
583    }
584
585    /// Installs a property map used for `${NAME}` interpolation.
586    ///
587    /// [`PropertySyntax::DockerCompose`] and [`PropertySyntax::DockerComposeWithNulls`] also
588    /// interpolate quoted and block string values; the other modes interpolate only plain scalars.
589    ///
590    /// This is the intended public API for the `properties` feature. It consumes the provided
591    /// [`HashMap`] and stores it in the internal shared representation used by nested
592    /// deserializers, so callers do not have to construct `Rc` or `Some(...)` manually.
593    ///
594    /// ```rust
595    /// # #[cfg(feature = "properties")]
596    /// # {
597    /// use std::collections::HashMap;
598    /// use serde_saphyr::Options;
599    ///
600    /// let mut properties = HashMap::new();
601    /// properties.insert("MODE".to_string(), "production".to_string());
602    ///
603    /// let options = Options::default().with_properties(properties);
604    /// # let _ = options;
605    /// # }
606    /// ```
607    #[cfg(feature = "properties")]
608    #[must_use]
609    pub fn with_properties(mut self, properties: HashMap<String, String>) -> Self {
610        self.property_map = Some(Rc::new(properties));
611        self
612    }
613
614    /// Sets the include resolver callback to be used during parsing.
615    ///
616    /// This method is for advances use cases. If you just want to include files from the
617    /// filesystem, use [`Options::with_filesystem_root`] instead that will use [`crate::SafeFileResolver`]
618    ///
619    /// The callback is invoked each time the parser encounters a `!include` tag. It receives an
620    /// [`crate::input_source::IncludeRequest`] describing the requested include target, the source
621    /// that triggered it, the include stack, and the source location. The callback must then
622    /// return either a [`crate::ResolvedInclude`] or a [`crate::IncludeResolveError`].
623    ///
624    /// This is useful for virtual filesystems, embedded configuration bundles, network-backed
625    /// resolvers, or custom caching layers.
626    ///
627    /// ```rust
628    /// # #[cfg(feature = "include")]
629    /// # {
630    /// use serde::Deserialize;
631    /// use serde_saphyr::{
632    ///     from_str_with_options, options, IncludeRequest, IncludeResolveError, InputSource,
633    ///     ResolvedInclude,
634    /// };
635    ///
636    /// #[derive(Debug, Deserialize, PartialEq)]
637    /// struct Config {
638    ///     users: Vec<User>,
639    /// }
640    ///
641    /// #[derive(Debug, Deserialize, PartialEq)]
642    /// struct User {
643    ///     name: String,
644    /// }
645    ///
646    /// let root_yaml = "users: !include virtual://users.yaml\n";
647    /// let users_yaml = "- name: Alice\n- name: Bob\n";
648    ///
649    /// let options = options! {}.with_include_resolver(|req: IncludeRequest<'_>| {
650    ///     assert_eq!(req.spec, "virtual://users.yaml");
651    ///     assert_eq!(req.from_name, "<input>");
652    ///
653    ///     if req.spec == "virtual://users.yaml" {
654    ///         Ok(ResolvedInclude::new(
655    ///             req.spec,
656    ///             "virtual users",
657    ///             InputSource::from_string(users_yaml.to_owned()),
658    ///         ))
659    ///     } else {
660    ///         Err(IncludeResolveError::Message(format!("unknown include: {}", req.spec)))
661    ///     }
662    /// });
663    ///
664    /// let config: Config = from_str_with_options(root_yaml, options).unwrap();
665    /// assert_eq!(config.users.len(), 2);
666    /// assert_eq!(config.users[0].name, "Alice");
667    /// # }
668    /// ```
669    #[cfg(feature = "include")]
670    #[must_use]
671    pub fn with_include_resolver<F>(mut self, cb: F) -> Self
672    where
673        F: for<'res> FnMut(
674                crate::input_source::IncludeRequest<'res>,
675            )
676                -> Result<crate::ResolvedInclude, crate::IncludeResolveError>
677            + 'static,
678    {
679        self.include_resolver = Some(Rc::new(std::cell::RefCell::new(cb)));
680        self
681    }
682
683    /// Configures a [`crate::SafeFileResolver`] rooted at `path` for `!include` lookups.
684    ///
685    /// This is a convenience for:
686    ///
687    /// ```rust,no_run
688    /// # #[cfg(feature = "include_fs")]
689    /// # fn main() -> Result<(), std::io::Error> {
690    /// # use serde_saphyr::{options, SafeFileResolver};
691    /// let options = options! {}
692    ///     .with_include_resolver(SafeFileResolver::new("./configs")?.into_callback());
693    /// # let _ = options;
694    /// # Ok(())
695    /// # }
696    /// ```
697    ///
698    /// It enables filesystem-backed `!include` resolution.
699    ///
700    /// ```rust,no_run
701    /// # #[cfg(feature = "include_fs")]
702    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
703    /// use serde::Deserialize;
704    /// use serde_saphyr::{from_str_with_options, options};
705    ///
706    /// #[derive(Debug, Deserialize)]
707    /// struct User {
708    ///     name: String,
709    /// }
710    ///
711    /// #[derive(Debug, Deserialize)]
712    /// struct Config {
713    ///     users: Vec<User>,
714    /// }
715    ///
716    /// let yaml = "users: !include#users value.yaml\n";
717    /// let options = options! {}.with_filesystem_root("./examples")?;
718    /// let config: Config = from_str_with_options(yaml, options)?;
719    /// # let _ = config;
720    /// # Ok(())
721    /// # }
722    /// ```
723    #[cfg(feature = "include_fs")]
724    pub fn with_filesystem_root<P>(self, path: P) -> io::Result<Self>
725    where
726        P: AsRef<Path>,
727    {
728        Ok(self.with_include_resolver(crate::SafeFileResolver::new(path)?.into_callback()))
729    }
730}
731
732#[allow(deprecated)] // Preserve the legacy default while the new field is unconfigured.
733impl Default for Options {
734    fn default() -> Self {
735        Self {
736            budget: Some(Budget::default()),
737            budget_report: None,
738            budget_report_cb: None,
739            #[cfg(feature = "comments")]
740            emit_comments: true,
741            strict_indentation: false,
742            duplicate_keys: DuplicateKeyPolicy::Error,
743            merge_keys: MergeKeyPolicy::Merge,
744            alias_limits: AliasLimits::default(),
745            legacy_octal_numbers: false,
746            leading_zero_numbers_as_strings: false,
747            underscored_numbers_as_strings: false,
748            strict_booleans: false,
749            angle_conversions: false,
750            ignore_binary_tag_for_string: false,
751            no_schema: false,
752            reject_unsupported_tags: false,
753            reject_tagged_field_names: false,
754            reject_non_finite_typeless_float: true,
755            with_snippet: true,
756            crop_radius: 64,
757            require_indent: RequireIndent::Unchecked,
758
759            #[cfg(feature = "include")]
760            include_resolver: None,
761            #[cfg(feature = "properties")]
762            property_map: None,
763            #[cfg(feature = "properties")]
764            property_syntax: PropertySyntax::Braced,
765            non_finite_float_policy: NonFiniteFloatPolicy::default(),
766        }
767    }
768}
769
770#[allow(deprecated)] // Include the legacy setting in diagnostics.
771impl std::fmt::Debug for Options {
772    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
773        let mut debug = f.debug_struct("Options");
774        debug
775            .field("budget", &self.budget)
776            .field("budget_report", &self.budget_report)
777            .field(
778                "budget_report_cb",
779                &if self.budget_report_cb.is_some() {
780                    "set"
781                } else {
782                    "none"
783                },
784            );
785        #[cfg(feature = "comments")]
786        debug.field("emit_comments", &self.emit_comments);
787        debug
788            .field("strict_indentation", &self.strict_indentation)
789            .field("duplicate_keys", &self.duplicate_keys)
790            .field("merge_keys", &self.merge_keys)
791            .field("alias_limits", &self.alias_limits)
792            .field("legacy_octal_numbers", &self.legacy_octal_numbers)
793            .field(
794                "leading_zero_numbers_as_strings",
795                &self.leading_zero_numbers_as_strings,
796            )
797            .field(
798                "underscored_numbers_as_strings",
799                &self.underscored_numbers_as_strings,
800            )
801            .field("strict_booleans", &self.strict_booleans)
802            .field(
803                "ignore_binary_tag_for_string",
804                &self.ignore_binary_tag_for_string,
805            )
806            .field("angle_conversions", &self.angle_conversions)
807            .field("no_schema", &self.no_schema)
808            .field("reject_unsupported_tags", &self.reject_unsupported_tags)
809            .field("reject_tagged_field_names", &self.reject_tagged_field_names)
810            .field("non_finite_float_policy", &self.non_finite_float_policy)
811            .field(
812                "reject_non_finite_typeless_float",
813                &self.reject_non_finite_typeless_float,
814            )
815            .field("with_snippet", &self.with_snippet)
816            .field("crop_radius", &self.crop_radius)
817            .field("require_indent", &self.require_indent)
818            .field("include_resolver", &{
819                #[cfg(feature = "include")]
820                {
821                    if self.include_resolver.is_some() {
822                        "set"
823                    } else {
824                        "none"
825                    }
826                }
827                #[cfg(not(feature = "include"))]
828                {
829                    "disabled"
830                }
831            })
832            .field("property_map", &{
833                #[cfg(feature = "properties")]
834                {
835                    if self.property_map.is_some() {
836                        "set"
837                    } else {
838                        "none"
839                    }
840                }
841                #[cfg(not(feature = "properties"))]
842                {
843                    "disabled"
844                }
845            })
846            .field("property_syntax", &{
847                #[cfg(feature = "properties")]
848                {
849                    format!("{:?}", self.property_syntax)
850                }
851                #[cfg(not(feature = "properties"))]
852                {
853                    "disabled".to_string()
854                }
855            })
856            .finish()
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    #[cfg(feature = "include_fs")]
865    use crate::input_source::{IncludeRequest, InputSource};
866    #[cfg(feature = "include_fs")]
867    use std::path::PathBuf;
868    #[cfg(feature = "include_fs")]
869    use tempfile::tempdir;
870
871    #[test]
872    fn test_options_default() {
873        let opts = Options::default();
874        assert!(opts.budget.is_some());
875        assert!(opts.budget_report.is_none());
876        assert!(opts.budget_report_cb.is_none());
877        #[cfg(feature = "comments")]
878        assert!(opts.emit_comments);
879        assert!(!opts.strict_indentation);
880        assert!(matches!(opts.duplicate_keys, DuplicateKeyPolicy::Error));
881        assert!(matches!(opts.merge_keys, MergeKeyPolicy::Merge));
882        assert_eq!(opts.alias_limits.max_total_replayed_events, 1_000_000);
883        assert!(!opts.legacy_octal_numbers);
884        assert!(!opts.leading_zero_numbers_as_strings);
885        assert!(!opts.underscored_numbers_as_strings);
886        assert!(!opts.strict_booleans);
887        assert!(!opts.ignore_binary_tag_for_string);
888        assert!(!opts.angle_conversions);
889        assert!(!opts.no_schema);
890        assert!(!opts.reject_unsupported_tags);
891        assert!(!opts.reject_tagged_field_names);
892        assert_eq!(
893            opts.non_finite_float_policy,
894            NonFiniteFloatPolicy::default()
895        );
896        assert!(opts.with_snippet);
897        assert_eq!(opts.crop_radius, 64);
898        assert_eq!(opts.require_indent, RequireIndent::Unchecked);
899
900        #[cfg(feature = "include")]
901        assert!(opts.include_resolver.is_none());
902        #[cfg(feature = "properties")]
903        {
904            assert!(opts.property_map.is_none());
905            assert_eq!(opts.property_syntax, PropertySyntax::Braced);
906        }
907    }
908
909    #[cfg(feature = "serde_derived_types")]
910    #[test]
911    fn duplicate_key_policy_serde_uses_snake_case() {
912        assert_eq!(
913            serde_json::to_string(&DuplicateKeyPolicy::FirstWins).unwrap(),
914            "\"first_wins\""
915        );
916        assert!(matches!(
917            serde_json::from_str::<DuplicateKeyPolicy>("\"last_wins\"").unwrap(),
918            DuplicateKeyPolicy::LastWins
919        ));
920        assert!(serde_json::from_str::<DuplicateKeyPolicy>("\"FirstWins\"").is_err());
921    }
922
923    #[cfg(feature = "serde_derived_types")]
924    #[test]
925    fn tagged_field_name_policy_serde_roundtrip_and_missing_default() {
926        let options = crate::options! { reject_tagged_field_names: true };
927        let mut json = serde_json::to_value(&options).unwrap();
928        let restored: Options = serde_json::from_value(json.clone()).unwrap();
929        assert!(restored.reject_tagged_field_names);
930
931        json.as_object_mut()
932            .unwrap()
933            .remove("reject_tagged_field_names");
934        let legacy: Options = serde_json::from_value(json).unwrap();
935        assert!(!legacy.reject_tagged_field_names);
936    }
937
938    #[cfg(feature = "serde_derived_types")]
939    #[test]
940    fn strict_indentation_serde_roundtrip_and_missing_default() {
941        let options = crate::options! { strict_indentation: true };
942        let mut json = serde_json::to_value(&options).unwrap();
943        let restored: Options = serde_json::from_value(json.clone()).unwrap();
944        assert!(restored.strict_indentation);
945
946        json.as_object_mut().unwrap().remove("strict_indentation");
947        let legacy: Options = serde_json::from_value(json).unwrap();
948        assert!(!legacy.strict_indentation);
949    }
950
951    #[cfg(feature = "serde_derived_types")]
952    #[test]
953    fn non_finite_float_policy_serde_uses_snake_case() {
954        for (policy, name) in [
955            (NonFiniteFloatPolicy::Default, "default"),
956            (NonFiniteFloatPolicy::PassThrough, "pass_through"),
957            (NonFiniteFloatPolicy::Reject, "reject"),
958            (NonFiniteFloatPolicy::AsString, "as_string"),
959        ] {
960            let json = serde_json::to_string(&policy).unwrap();
961            assert_eq!(json, format!("\"{name}\""));
962            assert_eq!(
963                serde_json::from_str::<NonFiniteFloatPolicy>(&json).unwrap(),
964                policy
965            );
966        }
967        assert!(serde_json::from_str::<NonFiniteFloatPolicy>("\"PassThrough\"").is_err());
968    }
969
970    #[test]
971    fn test_options_debug_format() {
972        let opts = Options::default();
973        let debug_str = format!("{:?}", opts);
974        assert!(debug_str.contains("Options"));
975        assert!(debug_str.contains("budget"));
976        assert!(debug_str.contains("budget_report_cb: \"none\""));
977        #[cfg(feature = "comments")]
978        assert!(debug_str.contains("emit_comments: true"));
979        assert!(debug_str.contains("strict_indentation: false"));
980        assert!(debug_str.contains("reject_unsupported_tags: false"));
981        assert!(debug_str.contains("reject_tagged_field_names: false"));
982        assert!(debug_str.contains("non_finite_float_policy: Default"));
983        assert!(debug_str.contains("reject_non_finite_typeless_float: true"));
984
985        #[cfg(feature = "include")]
986        assert!(debug_str.contains("include_resolver: \"none\""));
987        #[cfg(feature = "properties")]
988        {
989            assert!(debug_str.contains("property_map: \"none\""));
990        }
991        #[cfg(not(feature = "properties"))]
992        {
993            assert!(debug_str.contains("property_map: \"disabled\""));
994        }
995
996        // Test with callback
997        let opts_with_cb = opts.with_budget_report(|_| {});
998        let debug_str_cb = format!("{:?}", opts_with_cb);
999        assert!(debug_str_cb.contains("budget_report_cb: \"set\""));
1000    }
1001
1002    #[cfg(feature = "properties")]
1003    #[test]
1004    fn test_with_properties_sets_property_map() {
1005        let mut properties = std::collections::HashMap::new();
1006        properties.insert("MODE".to_string(), "production".to_string());
1007
1008        let opts = Options::default().with_properties(properties);
1009
1010        assert_eq!(
1011            opts.property_map.as_deref().unwrap().get("MODE"),
1012            Some(&"production".to_string())
1013        );
1014    }
1015
1016    #[test]
1017    fn test_alias_limits_default() {
1018        let limits = AliasLimits::default();
1019        assert_eq!(limits.max_total_replayed_events, 1_000_000);
1020        assert_eq!(limits.max_replay_stack_depth, 64);
1021        assert_eq!(limits.max_alias_expansions_per_anchor, usize::MAX);
1022    }
1023
1024    #[test]
1025    fn test_alias_limits_macro() {
1026        let limits = crate::alias_limits! {
1027            max_total_replayed_events: 42,
1028            max_replay_stack_depth: 7,
1029        };
1030        assert_eq!(limits.max_total_replayed_events, 42);
1031        assert_eq!(limits.max_replay_stack_depth, 7);
1032        assert_eq!(limits.max_alias_expansions_per_anchor, usize::MAX);
1033
1034        let opts = crate::options! {
1035            alias_limits: crate::alias_limits! {
1036                max_alias_expansions_per_anchor: 9,
1037            },
1038        };
1039        assert_eq!(opts.alias_limits.max_alias_expansions_per_anchor, 9);
1040    }
1041
1042    #[cfg(feature = "include_fs")]
1043    #[test]
1044    #[cfg_attr(miri, ignore)]
1045    fn test_with_filesystem_root_sets_include_resolver() {
1046        let root = PathBuf::from(".");
1047        let opts = Options::default().with_filesystem_root(&root).unwrap();
1048        assert!(opts.include_resolver.is_some());
1049    }
1050
1051    #[cfg(feature = "include_fs")]
1052    #[test]
1053    #[cfg_attr(miri, ignore)]
1054    fn test_with_filesystem_root_uses_reader_default_for_regular_files() {
1055        let dir = tempdir().unwrap();
1056        std::fs::write(dir.path().join("child.yaml"), "value: 1\n").unwrap();
1057
1058        let opts = Options::default().with_filesystem_root(dir.path()).unwrap();
1059        let mut resolver = opts
1060            .include_resolver
1061            .as_ref()
1062            .expect("resolver set")
1063            .borrow_mut();
1064        let resolved = resolver(IncludeRequest {
1065            spec: "child.yaml",
1066            from_name: "<input>",
1067            from_id: None,
1068            stack: vec!["<input>".to_string()],
1069            location: crate::Location::UNKNOWN,
1070            size_remaining: None,
1071        })
1072        .unwrap();
1073
1074        assert!(matches!(resolved.source, InputSource::Reader(_)));
1075    }
1076}