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}