Skip to main content

noyalib/
ser.rs

1//! YAML serialization.
2
3// SPDX-License-Identifier: MIT OR Apache-2.0
4// Copyright (c) 2026 Noyalib. All rights reserved.
5
6use crate::prelude::*;
7use core::fmt::Write as _;
8
9use crate::error::{Error, Result};
10use crate::value::{Mapping, Number, Sequence, Tag, TaggedValue, Value};
11
12/// Flow style preference for collections.
13///
14/// Controls whether sequences and mappings should use inline (flow) or
15/// multi-line (block) style.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17#[non_exhaustive]
18pub enum FlowStyle {
19    /// Always use block style (multi-line).
20    #[default]
21    Block,
22    /// Always use flow style (inline, JSON-like).
23    Flow,
24    /// Automatic: use flow for small collections, block for larger ones.
25    Auto,
26}
27
28/// Scalar style preference for strings.
29///
30/// Controls how string values should be quoted.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[non_exhaustive]
33pub enum ScalarStyle {
34    /// Automatic quoting based on content.
35    #[default]
36    Auto,
37    /// Always use double quotes.
38    DoubleQuoted,
39    /// Always use single quotes.
40    SingleQuoted,
41    /// Use literal block style (|) for multiline.
42    Literal,
43    /// Use folded block style (>) for multiline.
44    Folded,
45    /// Plain (unquoted) style when possible.
46    Plain,
47}
48
49/// Configuration options for YAML serialization.
50///
51/// # Examples
52///
53/// ```rust
54/// use noyalib::{FlowStyle, ScalarStyle, SerializerConfig};
55///
56/// let config = SerializerConfig::new()
57///     .indent(4)
58///     .flow_style(FlowStyle::Auto)
59///     .scalar_style(ScalarStyle::DoubleQuoted)
60///     .document_start(true);
61/// ```
62#[derive(Debug, Clone, Copy)]
63#[non_exhaustive]
64pub struct SerializerConfig {
65    /// Number of spaces per indentation level (default: 2).
66    pub indent: usize,
67    /// Whether to include document start marker `---` (default: false).
68    pub document_start: bool,
69    /// Whether to include document end marker `...` (default: false).
70    pub document_end: bool,
71    /// Whether to use block style for multiline strings (default: true).
72    pub block_scalars: bool,
73    /// Minimum number of newlines to trigger block scalar style (default: 1).
74    pub block_scalar_threshold: usize,
75    /// Flow style preference for collections (default: Block).
76    pub flow_style: FlowStyle,
77    /// Scalar style preference for strings (default: Auto).
78    pub scalar_style: ScalarStyle,
79    /// Maximum number of items in a collection to use flow style in Auto mode
80    /// (default: 4).
81    pub flow_threshold: usize,
82    /// Force-quote all string scalars regardless of content (default: false).
83    pub quote_all: bool,
84    /// Prefer single quotes over double quotes when a string scalar needs
85    /// quoting at all (default: false).
86    ///
87    /// A string that contains a character only double-quoted style can
88    /// carry -- a control character, a tab, or any other code point that
89    /// needs an escape sequence -- still gets double-quoted regardless of
90    /// this setting, since single-quoted style has no escape mechanism
91    /// beyond doubling an embedded `'`.
92    pub prefer_single_quotes: bool,
93    /// Compact list indentation under mapping keys (default: false).
94    ///
95    /// When `true`, sequence items under a mapping key align with the key
96    /// instead of being indented an extra level.
97    pub compact_list_indent: bool,
98    /// Line width for folded block scalars (default: 80).
99    pub folded_wrap_chars: usize,
100    /// Minimum string length before block scalar style is considered (default: 80).
101    pub min_fold_chars: usize,
102    /// Maximum nesting depth allowed during serialization (default: 128).
103    pub max_depth: usize,
104}
105
106impl Default for SerializerConfig {
107    fn default() -> Self {
108        Self {
109            indent: 2,
110            document_start: false,
111            document_end: false,
112            block_scalars: true,
113            block_scalar_threshold: 1,
114            flow_style: FlowStyle::Block,
115            scalar_style: ScalarStyle::Auto,
116            flow_threshold: 4,
117            quote_all: false,
118            prefer_single_quotes: false,
119            compact_list_indent: false,
120            folded_wrap_chars: 80,
121            min_fold_chars: 80,
122            max_depth: 128,
123        }
124    }
125}
126
127impl SerializerConfig {
128    /// Create a new configuration with default settings.
129    #[must_use]
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Set the indentation width.
135    #[must_use]
136    pub fn indent(mut self, spaces: usize) -> Self {
137        self.indent = spaces;
138        self
139    }
140
141    /// Enable or disable document start marker `---`.
142    #[must_use]
143    pub fn document_start(mut self, enabled: bool) -> Self {
144        self.document_start = enabled;
145        self
146    }
147
148    /// Enable or disable document end marker `...`.
149    #[must_use]
150    pub fn document_end(mut self, enabled: bool) -> Self {
151        self.document_end = enabled;
152        self
153    }
154
155    /// Enable or disable block scalar style for multiline strings.
156    #[must_use]
157    pub fn block_scalars(mut self, enabled: bool) -> Self {
158        self.block_scalars = enabled;
159        self
160    }
161
162    /// Set minimum newlines to trigger block scalar style.
163    #[must_use]
164    pub fn block_scalar_threshold(mut self, count: usize) -> Self {
165        self.block_scalar_threshold = count;
166        self
167    }
168
169    /// Set the flow style preference for collections.
170    ///
171    /// - `FlowStyle::Block`: Always use multi-line block style
172    /// - `FlowStyle::Flow`: Always use inline flow style
173    /// - `FlowStyle::Auto`: Use flow for small collections
174    #[must_use]
175    pub fn flow_style(mut self, style: FlowStyle) -> Self {
176        self.flow_style = style;
177        self
178    }
179
180    /// Set the scalar style preference for strings.
181    ///
182    /// - `ScalarStyle::Auto`: Quote only when necessary
183    /// - `ScalarStyle::DoubleQuoted`: Always use double quotes
184    /// - `ScalarStyle::SingleQuoted`: Always use single quotes
185    /// - `ScalarStyle::Literal`: Use `|` for multiline
186    /// - `ScalarStyle::Folded`: Use `>` for multiline
187    /// - `ScalarStyle::Plain`: Unquoted when possible
188    #[must_use]
189    pub fn scalar_style(mut self, style: ScalarStyle) -> Self {
190        self.scalar_style = style;
191        self
192    }
193
194    /// Set the threshold for automatic flow style.
195    ///
196    /// Collections with this many or fewer items will use flow style
197    /// when `flow_style` is set to `Auto`.
198    #[must_use]
199    pub fn flow_threshold(mut self, threshold: usize) -> Self {
200        self.flow_threshold = threshold;
201        self
202    }
203
204    /// Force-quote all string scalars regardless of content.
205    #[must_use]
206    pub fn quote_all(mut self, enabled: bool) -> Self {
207        self.quote_all = enabled;
208        self
209    }
210
211    /// Prefer single quotes over double quotes when a string scalar needs
212    /// quoting at all.
213    ///
214    /// A string that needs a character only double-quoted style can carry
215    /// (a control character, a tab, or anything else that needs an escape
216    /// sequence) still gets double-quoted regardless of this setting.
217    /// Output is unchanged when this is left at its default (`false`).
218    #[must_use]
219    pub fn prefer_single_quotes(mut self, enabled: bool) -> Self {
220        self.prefer_single_quotes = enabled;
221        self
222    }
223
224    /// Enable compact list indentation under mapping keys.
225    ///
226    /// When enabled, sequence items align with the key rather than
227    /// being indented an extra level.
228    #[must_use]
229    pub fn compact_list_indent(mut self, enabled: bool) -> Self {
230        self.compact_list_indent = enabled;
231        self
232    }
233
234    /// Set the line width for folded block scalars.
235    #[must_use]
236    pub fn folded_wrap_chars(mut self, chars: usize) -> Self {
237        self.folded_wrap_chars = chars;
238        self
239    }
240
241    /// Set the minimum string length for block scalar style.
242    ///
243    /// Strings shorter than this threshold will not use block scalar
244    /// (`|` / `>`) style, even if they contain newlines.
245    #[must_use]
246    pub fn min_fold_chars(mut self, chars: usize) -> Self {
247        self.min_fold_chars = chars;
248        self
249    }
250
251    /// Set the maximum nesting depth for serialization.
252    #[must_use]
253    pub fn max_depth(mut self, depth: usize) -> Self {
254        self.max_depth = depth;
255        self
256    }
257}
258
259/// Serialize a Rust value to a YAML `String`.
260///
261/// Uses [`SerializerConfig::default`]: 2-space indent, no
262/// `---` / `...` markers, block style for collections, auto-style
263/// scalars, block scalars enabled.
264///
265/// # Errors
266///
267/// Returns [`Error`](crate::Error) when:
268///
269/// - `Error::Serialize` — `T`'s `Serialize` impl returned an
270///   error (custom `serde_core::ser::Error`, non-string mapping key
271///   that cannot be coerced, …).
272/// - `Error::DepthLimit` — the value graph exceeds
273///   `SerializerConfig::max_depth` (default 128). Use
274///   [`to_string_with_config`] to raise the cap when serialising
275///   a deliberately deep structure.
276///
277/// `to_string` itself does not perform any I/O and never returns
278/// `Error::Io`.
279///
280/// # Examples
281///
282/// ```rust
283/// #[derive(serde::Serialize)]
284/// struct Config {
285///     name: String,
286///     port: u16,
287/// }
288///
289/// let config = Config {
290///     name: "myapp".to_string(),
291///     port: 8080,
292/// };
293///
294/// let yaml = noyalib::to_string(&config).unwrap();
295/// assert!(yaml.contains("name: myapp"));
296/// assert!(yaml.contains("port: 8080"));
297/// ```
298pub fn to_string<T>(value: &T) -> Result<String>
299where
300    T: ?Sized + serde_core::Serialize,
301{
302    let v = to_value(value)?;
303    value_to_string(&v, &SerializerConfig::default())
304}
305
306/// Serialize a Rust value to a YAML `String` with a custom
307/// [`SerializerConfig`].
308///
309/// # Errors
310///
311/// Same variant set as [`to_string`]. The active `config`
312/// controls which limit-related errors can fire — in particular,
313/// raising `max_depth` lets deeper graphs through, lowering it
314/// surfaces `Error::DepthLimit` sooner.
315///
316/// # Examples
317///
318/// ```rust
319/// use noyalib::SerializerConfig;
320///
321/// #[derive(serde::Serialize)]
322/// struct Config {
323///     name: String,
324///     port: u16,
325/// }
326///
327/// let config = Config {
328///     name: "myapp".to_string(),
329///     port: 8080,
330/// };
331///
332/// let yaml = noyalib::to_string_with_config(
333///     &config,
334///     &SerializerConfig::new().indent(4).document_start(true),
335/// )
336/// .unwrap();
337/// assert!(yaml.starts_with("---"));
338/// ```
339pub fn to_string_with_config<T>(value: &T, config: &SerializerConfig) -> Result<String>
340where
341    T: ?Sized + serde_core::Serialize,
342{
343    let v = to_value(value)?;
344    value_to_string(&v, config)
345}
346
347/// Serialize a Rust value to YAML and write to a
348/// [`std::io::Write`] sink.
349///
350/// Internally serialises to a `String` then writes the bytes to
351/// `writer` in a single call.
352///
353/// # Errors
354///
355/// - `Error::Io` — the underlying writer returned an I/O error.
356/// - All variants documented on [`to_string`].
357#[cfg(feature = "std")]
358pub fn to_writer<W, T>(writer: W, value: &T) -> Result<()>
359where
360    W: std::io::Write,
361    T: ?Sized + serde_core::Serialize,
362{
363    to_writer_with_config(writer, value, &SerializerConfig::default())
364}
365
366/// Serialize a Rust value to YAML and write to a
367/// [`std::io::Write`] sink, using a custom [`SerializerConfig`].
368///
369/// # Errors
370///
371/// - `Error::Io` — the underlying writer returned an I/O error.
372/// - All variants documented on [`to_string_with_config`].
373#[cfg(feature = "std")]
374pub fn to_writer_with_config<W, T>(writer: W, value: &T, config: &SerializerConfig) -> Result<()>
375where
376    W: std::io::Write,
377    T: ?Sized + serde_core::Serialize,
378{
379    let s = to_string_with_config(value, config)?;
380    let mut writer = writer;
381    writer.write_all(s.as_bytes())?;
382    Ok(())
383}
384
385/// Serialize a Rust type to a YAML string with automatic anchor/alias emission
386/// for shared `Rc` and `Arc` pointers wrapped in `RcAnchor` / `ArcAnchor`.
387///
388/// During this call, every `RcAnchor` / `ArcAnchor` whose pointer is seen for
389/// the first time emits a YAML anchor (`&idNNN`); every subsequent sighting of
390/// the same pointer emits an alias (`*idNNN`). This preserves true DAG
391/// structure in the emitted document — `Rc::clone` siblings become alias
392/// references instead of duplicated subtrees.
393///
394/// Pointer identity is tracked via a thread-local scratchpad that is installed
395/// for the duration of the call and cleared on return. Plain `to_string`
396/// behaviour is unaffected.
397///
398/// # Errors
399///
400/// Returns an error if the type cannot be serialized to YAML.
401///
402/// # Examples
403///
404/// ```rust
405/// use noyalib::{to_string_tracking_shared, RcAnchor};
406/// use std::rc::Rc;
407///
408/// let shared: RcAnchor<String> = RcAnchor::from("hello".to_string());
409/// let doc = vec![shared.clone(), shared.clone(), shared];
410/// let yaml = to_string_tracking_shared(&doc).unwrap();
411/// assert!(yaml.contains("&id001"));
412/// assert!(yaml.contains("*id001"));
413/// ```
414#[cfg(feature = "std")]
415pub fn to_string_tracking_shared<T>(value: &T) -> Result<String>
416where
417    T: ?Sized + serde_core::Serialize,
418{
419    to_string_tracking_shared_with_config(value, &SerializerConfig::default())
420}
421
422/// Serialize with automatic anchor/alias emission and a custom configuration.
423///
424/// See [`to_string_tracking_shared`] for behaviour.
425///
426/// # Errors
427///
428/// Returns an error if the type cannot be serialized to YAML.
429#[cfg(feature = "std")]
430pub fn to_string_tracking_shared_with_config<T>(
431    value: &T,
432    config: &SerializerConfig,
433) -> Result<String>
434where
435    T: ?Sized + serde_core::Serialize,
436{
437    let _scope = crate::anchors::shared_tracking::AnchorScope::enter();
438    to_string_with_config(value, config)
439}
440
441/// Write YAML to a writer with automatic anchor/alias emission for shared
442/// `Rc` / `Arc` pointers.
443///
444/// See [`to_string_tracking_shared`] for behaviour.
445///
446/// # Errors
447///
448/// Returns an error if the type cannot be serialized or writing fails.
449#[cfg(feature = "std")]
450pub fn to_writer_tracking_shared<W, T>(writer: W, value: &T) -> Result<()>
451where
452    W: std::io::Write,
453    T: ?Sized + serde_core::Serialize,
454{
455    to_writer_tracking_shared_with_config(writer, value, &SerializerConfig::default())
456}
457
458/// Write YAML to a writer with automatic anchor/alias emission and a custom
459/// configuration.
460///
461/// See [`to_string_tracking_shared`] for behaviour.
462///
463/// # Errors
464///
465/// Returns an error if the type cannot be serialized or writing fails.
466#[cfg(feature = "std")]
467pub fn to_writer_tracking_shared_with_config<W, T>(
468    writer: W,
469    value: &T,
470    config: &SerializerConfig,
471) -> Result<()>
472where
473    W: std::io::Write,
474    T: ?Sized + serde_core::Serialize,
475{
476    let s = to_string_tracking_shared_with_config(value, config)?;
477    let mut writer = writer;
478    writer.write_all(s.as_bytes())?;
479    Ok(())
480}
481
482/// Serialize a Rust value to YAML and write into a [`core::fmt::Write`]
483/// sink — the no_std-friendly counterpart to [`to_writer`].
484///
485/// # Errors
486///
487/// - `Error::Serialize` — the destination's `write_str` returned
488///   `fmt::Error`, propagated through `Error::Serialize`.
489/// - All variants documented on [`to_string`].
490pub fn to_fmt_writer<W, T>(writer: &mut W, value: &T) -> Result<()>
491where
492    W: fmt::Write,
493    T: ?Sized + serde_core::Serialize,
494{
495    to_fmt_writer_with_config(writer, value, &SerializerConfig::default())
496}
497
498/// Serialize a Rust value to YAML and write into a [`core::fmt::Write`]
499/// sink, using a custom [`SerializerConfig`].
500///
501/// # Errors
502///
503/// - `Error::Serialize` — the destination's `write_str` returned
504///   `fmt::Error`.
505/// - All variants documented on [`to_string_with_config`].
506pub fn to_fmt_writer_with_config<W, T>(
507    writer: &mut W,
508    value: &T,
509    config: &SerializerConfig,
510) -> Result<()>
511where
512    W: fmt::Write,
513    T: ?Sized + serde_core::Serialize,
514{
515    let s = to_string_with_config(value, config)?;
516    writer
517        .write_str(&s)
518        .map_err(|e| Error::Serialize(e.to_string()))
519}
520
521/// Serialize a Rust value into a dynamic [`Value`] tree via the
522/// Serde data model.
523///
524/// Use this when the typed serialise output should land in
525/// noyalib's [`Value`] for further programmatic editing
526/// (`Value::merge`, `Value::interpolate_properties`, …) before a
527/// final emit.
528///
529/// **Note**: [`TaggedValue`]'s `Serialize` impl (which `Value::Tagged`
530/// delegates to) routes through `serialize_map` with a single entry keyed
531/// by the tag string — the right shape for interop with a generic
532/// serializer that has no YAML-tag concept, `serde_json` and friends
533/// included — wrapped in a newtype struct carrying a private marker name.
534/// This crate's own [`Serializer`] *does* have a tag concept: it
535/// recognises the marker (never the map's shape) when it builds the
536/// resulting [`Value`] and reconstructs [`Value::Tagged`], so
537/// `to_value`/`to_string` on a `Value` containing `Tagged` round-trips the
538/// tag rather than losing it to a degenerate one-entry mapping, while a
539/// genuine one-entry mapping keyed by a `!`-string stays a mapping. Refs
540/// #350, #377. For direct emission of a `Value` you already hold, without going
541/// through the `Serialize` pipeline at all, see [`to_string_value`] /
542/// [`to_writer_value`].
543///
544/// # Errors
545///
546/// - `Error::Serialize` — `T`'s `Serialize` impl returned an
547///   error.
548/// - `Error::Custom` — surfaces upstream `serde_core::ser::Error`
549///   conversions that don't fit the structured variants.
550pub fn to_value<T>(value: &T) -> Result<Value>
551where
552    T: ?Sized + serde_core::Serialize,
553{
554    // The public `to_value` / `to_string` family keeps
555    // `T: ?Sized + serde_core::Serialize` so callers can serialise structs
556    // holding borrowed references. `Value::Tagged`'s tag survives this path
557    // via `SerializeMap::end`'s single-entry-map reconstruction (see its
558    // doc comment); users who already hold a `Value` and want to skip the
559    // `Serialize` pipeline entirely can still call [`to_string_value`] /
560    // [`to_string_value_with_config`] / [`to_writer_value`].
561    value.serialize(Serializer)
562}
563
564/// Serialize a [`Value`] directly to a YAML `String`, preserving
565/// [`Value::Tagged`] shape losslessly.
566///
567/// This function bypasses the `Serialize` pipeline entirely and writes
568/// the YAML-tag prefix directly. [`to_string`]/[`to_value`] also preserve
569/// `Value::Tagged` when `T` is (or contains) a `Value` — see the note on
570/// [`to_value`] — but this one skips the round trip through `Serializer`
571/// altogether.
572///
573/// Use this whenever you hold a `Value` that may contain
574/// `Value::Tagged` and want the emitted YAML to round-trip back
575/// into an equivalent `Value::Tagged`.
576///
577/// # Errors
578///
579/// All variants documented on [`to_string`].
580///
581/// # Examples
582///
583/// ```
584/// use noyalib::{from_str, to_string_value, Value};
585/// let v: Value = from_str("!Color '#ff8800'\n").unwrap();
586/// assert!(matches!(v, Value::Tagged(_)));
587/// let s = to_string_value(&v).unwrap();
588/// // Re-parsing yields an equivalent `Value::Tagged`.
589/// let back: Value = from_str(&s).unwrap();
590/// assert!(matches!(back, Value::Tagged(_)));
591/// ```
592pub fn to_string_value(value: &Value) -> Result<String> {
593    value_to_string(value, &SerializerConfig::default())
594}
595
596/// Serialize a [`Value`] to a YAML `String` with a custom
597/// [`SerializerConfig`], preserving [`Value::Tagged`] shape
598/// losslessly. See [`to_string_value`] for the rationale.
599///
600/// # Errors
601///
602/// All variants documented on [`to_string_with_config`].
603pub fn to_string_value_with_config(value: &Value, config: &SerializerConfig) -> Result<String> {
604    value_to_string(value, config)
605}
606
607/// Write a [`Value`] to an [`std::io::Write`] sink, preserving
608/// [`Value::Tagged`] shape losslessly. See [`to_string_value`]
609/// for the rationale.
610///
611/// # Errors
612///
613/// - `Error::Io` — the underlying writer returned an I/O error.
614/// - All variants documented on [`to_string`].
615#[cfg(feature = "std")]
616pub fn to_writer_value<W>(writer: W, value: &Value) -> Result<()>
617where
618    W: std::io::Write,
619{
620    to_writer_value_with_config(writer, value, &SerializerConfig::default())
621}
622
623/// Write a [`Value`] to an [`std::io::Write`] sink with a custom
624/// [`SerializerConfig`], preserving [`Value::Tagged`] shape
625/// losslessly. See [`to_string_value`] for the rationale.
626///
627/// # Errors
628///
629/// - `Error::Io` — the underlying writer returned an I/O error.
630/// - All variants documented on [`to_string_with_config`].
631#[cfg(feature = "std")]
632pub fn to_writer_value_with_config<W>(
633    writer: W,
634    value: &Value,
635    config: &SerializerConfig,
636) -> Result<()>
637where
638    W: std::io::Write,
639{
640    let s = to_string_value_with_config(value, config)?;
641    let mut writer = writer;
642    writer.write_all(s.as_bytes())?;
643    Ok(())
644}
645
646fn value_to_string(value: &Value, config: &SerializerConfig) -> Result<String> {
647    let mut output = String::with_capacity(estimate_yaml_size(value));
648    if config.document_start {
649        output.push_str("---\n");
650    }
651    write_value(&mut output, value, 0, true, config, 0)?;
652    if config.document_end {
653        output.push_str("\n...");
654    }
655    Ok(output)
656}
657
658fn estimate_yaml_size(value: &Value) -> usize {
659    match value {
660        Value::Null => 4,
661        Value::Bool(_) => 5,
662        Value::Number(_) => 12,
663        Value::String(s) => s.len() + 4,
664        Value::Sequence(seq) => 4 + seq.iter().map(|v| estimate_yaml_size(v) + 4).sum::<usize>(),
665        Value::Mapping(map) => {
666            4 + map
667                .iter()
668                .map(|(k, v)| k.len() + estimate_yaml_size(v) + 6)
669                .sum::<usize>()
670        }
671        Value::Tagged(t) => 20 + estimate_yaml_size(t.value()),
672    }
673}
674
675/// Write `total_spaces` space characters to `output` without heap allocation.
676#[inline]
677fn write_indent(output: &mut String, total_spaces: usize) {
678    const SPACES: &str = "                                                                ";
679    // 64 spaces - covers indent up to depth 32 with indent=2
680    let mut remaining = total_spaces;
681    while remaining > 0 {
682        let n = remaining.min(SPACES.len());
683        output.push_str(&SPACES[..n]);
684        remaining -= n;
685    }
686}
687
688fn write_value(
689    output: &mut String,
690    value: &Value,
691    indent: usize,
692    is_root: bool,
693    config: &SerializerConfig,
694    depth: usize,
695) -> Result<()> {
696    if depth > config.max_depth {
697        return Err(Error::RecursionLimitExceeded { depth });
698    }
699    match value {
700        Value::Null => output.push_str("null"),
701        Value::Bool(b) => output.push_str(if *b { "true" } else { "false" }),
702        Value::Number(Number::Integer(n)) => {
703            #[cfg(feature = "fast-int")]
704            {
705                let mut buf = itoa::Buffer::new();
706                output.push_str(buf.format(*n));
707            }
708            #[cfg(not(feature = "fast-int"))]
709            {
710                let _ = write!(output, "{n}");
711            }
712        }
713        #[cfg(feature = "lossless-u64")]
714        Value::Number(Number::Unsigned(n)) => {
715            #[cfg(feature = "fast-int")]
716            {
717                let mut buf = itoa::Buffer::new();
718                output.push_str(buf.format(*n));
719            }
720            #[cfg(not(feature = "fast-int"))]
721            {
722                let _ = write!(output, "{n}");
723            }
724        }
725        Value::Number(Number::Float(n)) => {
726            // Shared with `Number`'s `Display` impl (see #348) so the
727            // two never disagree on how a float prints.
728            let _ = crate::value::write_float(output, *n);
729        }
730        Value::String(s) => write_string(output, s, indent, config),
731        Value::Sequence(seq) => write_sequence(output, seq, indent, is_root, config, depth)?,
732        Value::Mapping(map) => write_mapping(output, map, indent, is_root, config, depth)?,
733        Value::Tagged(tagged) => {
734            let tag_str = tagged.tag().as_str();
735            if tag_str.starts_with("__noya_") {
736                write_internal_tag(
737                    output,
738                    tag_str,
739                    tagged.value(),
740                    indent,
741                    is_root,
742                    config,
743                    depth,
744                )?;
745            } else {
746                // Write the tag, then its payload. A scalar payload sits on
747                // this same line after a space (`!tag value`); a non-empty
748                // mapping/sequence payload starts block layout on the next
749                // line instead, with no trailing space after the tag (that
750                // space would never be followed by anything, so it would
751                // survive only as trailing whitespace). `indent` here is
752                // already the slot this caller computed for the *whole*
753                // tagged value via `needs_block_layout`/`indicator_takes_a_space`
754                // above (see `write_mapping`/`write_sequence`), so the
755                // payload is written at that same `indent`, not one deeper.
756                // A tag whose body holds characters the shorthand
757                // spelling cannot carry — flow indicators, blanks, or
758                // an interior `!` (a handle separator there) — is
759                // emitted in the verbatim form `!<...>`, which
760                // re-parses to exactly the stored tag (`!<!str>` is
761                // `!!str`). Emitting it raw produced YAML that split
762                // at the first such byte: `!<tag:example.com,2026:x>`
763                // re-emitted as shorthand died at the comma (found by
764                // fuzz_roundtrip).
765                // …and a tag body NO spelling can carry — a control
766                // character (the scanner rejects those in shorthand
767                // and verbatim forms alike; a tab is one) or a `>`
768                // (verbatim's terminator, rejected in shorthand as a
769                // non-URI char) — resolves the serde-model ambiguity
770                // the other way: in the serde data model a tagged
771                // value is indistinguishable from the single-entry
772                // mapping keyed by its `!`-leading spelling, so emit
773                // that mapping with a quoted key and let it re-parse
774                // as what it is (found by fuzz_roundtrip on the key
775                // `"!\t"`).
776                if tag_str.bytes().any(|b| b < 0x20 || b == 0x7f || b == b'>') {
777                    write_key_string(output, tag_str, indent, config);
778                    output.push(':');
779                    let inner = tagged.value();
780                    if indicator_takes_a_space(inner) {
781                        output.push(' ');
782                    }
783                    write_value(output, inner, indent, false, config, depth + 1)?;
784                    return Ok(());
785                }
786                let shorthand_body = tag_str
787                    .strip_prefix("!!")
788                    .or_else(|| tag_str.strip_prefix('!'));
789                let needs_verbatim = shorthand_body.is_some_and(|body| {
790                    body.bytes().any(|b| {
791                        matches!(b, b',' | b'[' | b']' | b'{' | b'}' | b'!' | b' ' | b'\t')
792                    })
793                });
794                if needs_verbatim {
795                    output.push_str("!<");
796                    output.push_str(&tag_str[1..]);
797                    output.push('>');
798                } else {
799                    output.push_str(tag_str);
800                }
801                let inner = tagged.value();
802                if indicator_takes_a_space(inner) {
803                    output.push(' ');
804                }
805                write_value(output, inner, indent, false, config, depth + 1)?;
806            }
807        }
808    }
809    Ok(())
810}
811
812/// Fast check whether a plain scalar would be interpreted as a number by a YAML
813/// parser.  This is intentionally over-inclusive to ensure roundtrip safety —
814/// it's cheaper to quote a few extra strings than to lose data.
815fn looks_like_number(s: &str) -> bool {
816    let bytes = s.as_bytes();
817    if bytes.is_empty() {
818        return false;
819    }
820
821    // YAML special float literals (case variants)
822    if matches!(
823        s,
824        ".inf"
825            | ".Inf"
826            | ".INF"
827            | "+.inf"
828            | "+.Inf"
829            | "+.INF"
830            | "-.inf"
831            | "-.Inf"
832            | "-.INF"
833            | ".nan"
834            | ".NaN"
835            | ".NAN"
836    ) {
837        return true;
838    }
839
840    // Skip any leading signs (yaml-rust2 is permissive with e.g. "++1")
841    let mut i = 0;
842    while i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
843        i += 1;
844    }
845    if i >= bytes.len() {
846        return false;
847    }
848
849    let rest = &bytes[i..];
850
851    // A digit, or "." and a digit (floats like .5), can open a number.
852    // That is the cheap filter; the parser's own resolver has the last
853    // word, so digit-leading text it keeps as a string (`2026-12-31`,
854    // `1.2.3`, `3rd`, `1/2`) is written plain, as the author wrote it.
855    // The resolver sees the text after the signs: a permissive reader
856    // (yaml-rust2 accepts `++1`) may take stacked signs as one, so what
857    // follows them decides.
858    let candidate =
859        rest[0].is_ascii_digit() || (rest[0] == b'.' && rest.len() > 1 && rest[1].is_ascii_digit());
860    candidate && resolves_as_non_string(&s[i..])
861}
862
863/// The parser's verdict on a plain scalar: would it read back as a
864/// number, a boolean, or null rather than a string?
865///
866/// Runs the resolver the loaders and the streaming path share, with the
867/// YAML 1.1 legacy forms enabled (`0`-prefixed octals, sexagesimals) so a
868/// string is quoted whenever any reader configuration would turn it into
869/// something else.
870fn resolves_as_non_string(s: &str) -> bool {
871    !matches!(
872        crate::streaming::resolve_plain_ext(s, false, true, false, true, true, false),
873        crate::streaming::Scalar::Str(_)
874    )
875}
876
877/// A `:` ends a plain scalar only when a space, a tab, a flow indicator,
878/// or the end of the text follows it (YAML 1.2 plain scalars), so
879/// `word:count`, `10:00:00Z`, and `http://` stay plain.
880fn colon_ends_plain(bytes: &[u8], i: usize) -> bool {
881    match bytes.get(i + 1) {
882        None => true,
883        Some(&next) => matches!(next, b' ' | b'\t' | b',' | b'[' | b']' | b'{' | b'}'),
884    }
885}
886
887/// A `#` starts a comment only at the start of the text or after
888/// whitespace, so `a#b` stays plain.
889fn hash_starts_comment(bytes: &[u8], i: usize) -> bool {
890    i == 0 || matches!(bytes[i - 1], b' ' | b'\t')
891}
892
893/// Lookup table: true if the byte can require the string to be quoted.
894/// Covers: control chars (except tab), colon, hash, newline, etc. A colon
895/// or a hash is then judged in context by `colon_ends_plain` and
896/// `hash_starts_comment`.
897static NEEDS_QUOTE_BYTE: [bool; 128] = {
898    let mut t = [false; 128];
899    // Control characters (except tab 0x09)
900    let mut i = 0u8;
901    while i < 0x20 {
902        if i != b'\t' {
903            t[i as usize] = true;
904        }
905        i += 1;
906    }
907    // YAML structural characters
908    t[b':' as usize] = true;
909    t[b'#' as usize] = true;
910    t[b'\n' as usize] = true;
911    t[b'\r' as usize] = true;
912    t[b'\0' as usize] = true;
913    t
914};
915
916/// Characters that require quoting when they appear as the first character.
917static FIRST_CHAR_QUOTE: [bool; 128] = {
918    let mut t = [false; 128];
919    t[b' ' as usize] = true;
920    t[b'-' as usize] = true;
921    t[b'&' as usize] = true;
922    t[b'*' as usize] = true;
923    t[b'!' as usize] = true;
924    t[b'|' as usize] = true;
925    t[b'>' as usize] = true;
926    t[b'%' as usize] = true;
927    t[b'@' as usize] = true;
928    t[b'`' as usize] = true;
929    t[b'{' as usize] = true;
930    t[b'}' as usize] = true;
931    t[b'[' as usize] = true;
932    t[b']' as usize] = true;
933    t[b',' as usize] = true;
934    t[b'?' as usize] = true;
935    t[b'\'' as usize] = true;
936    t[b'"' as usize] = true;
937    t
938};
939
940/// A character only double-quoted style can carry faithfully: CR, NEL
941/// (U+0085), LS (U+2028), PS (U+2029) — and a BOM (U+FEFF). A literal
942/// block scalar normalises `\r` into the block's own line breaks, and
943/// the three Unicode separators pass through plain and single-quoted
944/// styles as raw bytes that 1.1-era parsers (and this crate's own
945/// reader) fold as line breaks, so a round trip changes the string
946/// (#335). A raw BOM is worse: the reader must not accept one inside
947/// a document at all (§5.2), and a string-leading BOM emitted plain
948/// is stream-skipped on re-parse, reinterpreting the rest of the
949/// scalar as markup (found by fuzz_roundtrip).
950///
951/// The C1 block (U+0080 to U+009F, NEL included) and the non-characters
952/// U+FFFE and U+FFFF sit outside `c-printable` (§5.1): this crate's
953/// reader takes them raw, but libyaml-based tools reject the document,
954/// so they force the same style with a hex escape (#379).
955fn needs_double_quoted_escape(s: &str) -> bool {
956    s.chars().any(|c| {
957        matches!(
958            c,
959            '\r' | '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}'
960        ) || ('\u{7f}'..='\u{9f}').contains(&c)
961            || (c < '\u{20}' && c != '\t' && c != '\n')
962    })
963}
964
965/// Write a mapping key. Keys are implicit (`key: value`) in every
966/// form this serializer emits, and an implicit key must fit on one
967/// line — the block scalar styles are not grammar there at all — so
968/// a string the value writer would render as a `|`/`>` block (any
969/// string holding a line break) is written double-quoted with
970/// escapes instead. Found by fuzz_roundtrip: a multi-line key
971/// emitted as a `|-` block produced YAML that no longer parsed
972/// ("expected block mapping key or end").
973fn write_key_string(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
974    if s.contains('\n') {
975        write_double_quoted(output, s);
976    } else {
977        write_string(output, s, indent, config);
978    }
979}
980
981fn write_string(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
982    let bytes = s.as_bytes();
983
984    // Empty string must be quoted
985    if bytes.is_empty() {
986        if config.prefer_single_quotes {
987            output.push_str("''");
988        } else {
989            output.push_str("\"\"");
990        }
991        return;
992    }
993
994    // Force-quote all strings when configured. Single-quoted style has
995    // no escapes, so a string only double-quoted style can carry still
996    // falls back regardless of the setting.
997    if config.quote_all {
998        if needs_double_quoted_escape(s) {
999            write_double_quoted(output, s);
1000        } else {
1001            write_single_quoted(output, s);
1002        }
1003        return;
1004    }
1005
1006    // A scalar starting with `...` emitted at the start of a line
1007    // reads back as the document-end marker (explicit-key emission
1008    // places keys at column 0), so it can never go plain — same
1009    // family as the `-` first-byte rule below, which already covers
1010    // `---` (found by fuzz_roundtrip on a `? ...` explicit key).
1011    if s.starts_with("...") {
1012        if needs_double_quoted_escape(s) {
1013            write_double_quoted(output, s);
1014        } else {
1015            write_single_quoted(output, s);
1016        }
1017        return;
1018    }
1019
1020    // Fast path: short ASCII strings that are clearly safe as plain scalars.
1021    // Avoids the full lookup table scan for the majority of mapping keys.
1022    //
1023    // The intent is: short, alnum-bounded, no newline. All four conditions
1024    // below are ANDed — `||` binds looser than `&&`, and an earlier version
1025    // of this guard read `a && b && c && !config.block_scalars ||
1026    // no_newline`, which let *every* newline-free string take the fast path
1027    // regardless of its first byte (`"-"` slipped through unquoted and
1028    // re-parsed as a block sequence entry, not a scalar). The first/last
1029    // alnum checks already exclude every `FIRST_CHAR_QUOTE` member (none of
1030    // them are alphanumeric) and tab (also not alphanumeric), but the
1031    // explicit `FIRST_CHAR_QUOTE` check is kept here too as defense in
1032    // depth against the alnum check alone being loosened later.
1033    if bytes.len() <= 64
1034        && bytes[0].is_ascii_alphanumeric()
1035        && bytes[bytes.len() - 1].is_ascii_alphanumeric()
1036        && bytes.iter().all(|&b| b != b'\n')
1037        && !(bytes[0] < 128 && FIRST_CHAR_QUOTE[bytes[0] as usize])
1038    {
1039        let safe = bytes.iter().all(|&b| {
1040            b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/'
1041        });
1042        if safe
1043            && !matches!(
1044                s,
1045                "true"
1046                    | "false"
1047                    | "null"
1048                    | "~"
1049                    | "True"
1050                    | "False"
1051                    | "TRUE"
1052                    | "FALSE"
1053                    | "Null"
1054                    | "NULL"
1055            )
1056            && !looks_like_number(s)
1057        {
1058            output.push_str(s);
1059            return;
1060        }
1061    }
1062
1063    // Block scalar for multiline strings -- unless the string carries a
1064    // character a block scalar cannot represent: `str::lines` and the
1065    // block's own line breaks erase a `\r` (#335).
1066    if config.block_scalars && !needs_double_quoted_escape(s) {
1067        let newlines = bytes.iter().filter(|&&b| b == b'\n').count();
1068        if newlines >= config.block_scalar_threshold {
1069            write_block_scalar(output, s, indent, config);
1070            return;
1071        }
1072    }
1073
1074    // CR and the Unicode line separators are representable only with
1075    // double-quoted escapes (#335).
1076    if needs_double_quoted_escape(s) {
1077        write_double_quoted(output, s);
1078        return;
1079    }
1080
1081    // Single-pass quoting decision
1082    let mut needs_quotes = false;
1083    let mut has_control = false;
1084
1085    // Check first character
1086    if bytes[0] < 128 && FIRST_CHAR_QUOTE[bytes[0] as usize] {
1087        needs_quotes = true;
1088    }
1089
1090    // A leading or trailing tab must quote. `NEEDS_QUOTE_BYTE` deliberately
1091    // excludes tab so an *interior* tab stays unescaped in a plain scalar,
1092    // but YAML 1.2 still requires quoting when a plain scalar's content
1093    // starts or ends in white space (tab included), or the boundary is
1094    // lost on re-parse.
1095    if bytes[0] == b'\t' || bytes[bytes.len() - 1] == b'\t' {
1096        needs_quotes = true;
1097    }
1098
1099    // Check last character (trailing space)
1100    if bytes[bytes.len() - 1] == b' ' {
1101        needs_quotes = true;
1102    }
1103
1104    // Reserved words
1105    if !needs_quotes {
1106        needs_quotes = matches!(
1107            s,
1108            "true" | "false" | "null" | "~" | "True" | "False" | "TRUE" | "FALSE" | "Null" | "NULL"
1109        ) || looks_like_number(s);
1110    }
1111
1112    // Single pass through interior bytes. A colon or a hash counts only
1113    // where YAML gives it meaning; see `colon_ends_plain` and
1114    // `hash_starts_comment`.
1115    if !needs_quotes {
1116        for (i, &b) in bytes.iter().enumerate() {
1117            if b >= 128 || !NEEDS_QUOTE_BYTE[b as usize] {
1118                continue;
1119            }
1120            if (b == b':' && !colon_ends_plain(bytes, i))
1121                || (b == b'#' && !hash_starts_comment(bytes, i))
1122            {
1123                continue;
1124            }
1125            if b < 0x20 && b != b'\t' {
1126                has_control = true;
1127            }
1128            needs_quotes = true;
1129            // Don't break - we need to know if there are control chars
1130        }
1131    }
1132
1133    if !needs_quotes {
1134        // Plain scalar - zero-copy output
1135        output.push_str(s);
1136        return;
1137    }
1138
1139    if config.prefer_single_quotes && single_quote_safe(s) {
1140        write_single_quoted(output, s);
1141        return;
1142    }
1143
1144    // Use double quotes for all quoted strings
1145    let _ = has_control;
1146    write_double_quoted(output, s);
1147}
1148
1149/// Whether `s` can be represented as a YAML single-quoted scalar with no
1150/// escapes beyond doubling an embedded `'`.
1151///
1152/// Single-quoted style has no escape mechanism at all besides doubling the
1153/// quote character itself — a literal backslash, double quote, `#`, `:`,
1154/// and so on all pass straight through unescaped. What it *cannot* carry is
1155/// a control character (tab, newline, carriage return, and the rest of the
1156/// C0/C1 ranges) or any other non-printable code point: those need one of
1157/// double-quoted style's escape sequences, so a string containing one must
1158/// fall back to double-quoted even when `prefer_single_quotes` is set.
1159fn single_quote_safe(s: &str) -> bool {
1160    !s.chars()
1161        .any(|c| c.is_control() || matches!(c, '\u{fffe}' | '\u{ffff}'))
1162}
1163
1164/// Write a single-quoted string, escaping embedded single quotes.
1165fn write_single_quoted(output: &mut String, s: &str) {
1166    output.push('\'');
1167    for c in s.chars() {
1168        if c == '\'' {
1169            output.push_str("''");
1170        } else {
1171            output.push(c);
1172        }
1173    }
1174    output.push('\'');
1175}
1176
1177/// Write a double-quoted string with bulk-copy between escape points.
1178fn write_double_quoted(output: &mut String, s: &str) {
1179    output.push('"');
1180    let mut start = 0;
1181    for (i, c) in s.char_indices() {
1182        let esc = match c {
1183            '"' => "\\\"",
1184            '\\' => "\\\\",
1185            '\n' => "\\n",
1186            '\r' => "\\r",
1187            '\t' => "\\t",
1188            '\0' => "\\0",
1189            // Named escapes for the non-ASCII line-break characters
1190            // (YAML 1.2 section 5.7): emitted raw they read back as
1191            // line breaks in 1.1-era parsers and in this crate's own
1192            // reader (#335).
1193            '\u{0085}' => "\\N",
1194            '\u{2028}' => "\\L",
1195            '\u{2029}' => "\\P",
1196            // A raw BOM must never reach the output stream — the
1197            // reader rejects one inside a document (§5.2).
1198            '\u{feff}' => "\\uFEFF",
1199            // The two non-characters sit outside `c-printable` (§5.1)
1200            // and have no named escape (#379).
1201            '\u{fffe}' | '\u{ffff}' => {
1202                output.push_str(&s[start..i]);
1203                let _ = write!(output, "\\u{:04X}", c as u32);
1204                start = i + c.len_utf8();
1205                continue;
1206            }
1207            c if (c as u32) < 0x20 || ('\u{7f}'..='\u{9f}').contains(&c) => {
1208                // Other control characters -- C0, DEL and the C1 block
1209                // (#379; NEL is matched above): flush and write the
1210                // two-digit hex escape. A C1 character is two bytes in
1211                // UTF-8, so advance by its width, not by one.
1212                output.push_str(&s[start..i]);
1213                let _ = write!(output, "\\x{:02X}", c as u32);
1214                start = i + c.len_utf8();
1215                continue;
1216            }
1217            _ => continue,
1218        };
1219        output.push_str(&s[start..i]);
1220        output.push_str(esc);
1221        start = i + c.len_utf8();
1222    }
1223    output.push_str(&s[start..]);
1224    output.push('"');
1225}
1226
1227/// Write a string using YAML literal block scalar style (|).
1228/// Write a block scalar's content lines at `indent + 1`.
1229///
1230/// An empty line gets **no** indentation. The block's indent is detected from
1231/// its first non-empty line, so an empty one has nothing to say; writing the
1232/// indent anyway leaves it standing as trailing whitespace on a line that
1233/// holds nothing, which `git diff --check` and `yamllint` reject.
1234///
1235/// This must stay one function. It had three identical copies — `|` auto, `|`
1236/// explicit and `>` — and the empty-line rule was missing from all three, so
1237/// fixing any one of them would have left the other two writing it.
1238fn write_block_scalar_body(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1239    for line in s.lines() {
1240        output.push('\n');
1241        if !line.is_empty() {
1242            write_indent(output, config.indent * (indent + 1));
1243            output.push_str(line);
1244        }
1245    }
1246}
1247
1248/// The explicit indentation indicator digit(s), if the block needs one.
1249///
1250/// YAML 1.2.2 §8.1.1.1: a literal/folded block scalar's indentation is
1251/// normally *auto-detected* from its first non-empty content line. That
1252/// detection breaks when the first content line itself starts with a
1253/// space or a tab — the leading whitespace gets folded into the detected
1254/// indentation, inflating it past what later, less-indented lines carry,
1255/// which a parser then rejects as inconsistent indentation. The fix is an
1256/// explicit indentation indicator between the block style character
1257/// (`|`/`>`) and the chomping indicator, stating the indentation as a
1258/// number of columns *beyond the parent node's own indentation*.
1259///
1260/// [`write_block_scalar_body`] always places content `config.indent`
1261/// columns beyond the indentation this function's caller was handed for
1262/// this value's slot (the parent node's own indentation) — so whenever an
1263/// indicator is needed, its value is exactly `config.indent`, independent
1264/// of nesting depth.
1265fn block_scalar_indent_indicator(s: &str, config: &SerializerConfig) -> String {
1266    let first_content_line = s.lines().find(|line| !line.is_empty());
1267    match first_content_line {
1268        Some(line) if line.starts_with(' ') || line.starts_with('\t') => config.indent.to_string(),
1269        _ => String::new(),
1270    }
1271}
1272
1273/// The chomping indicator for a block scalar holding `s` (YAML 1.2.2
1274/// §8.1.1.2): strip when the text has no trailing line break, keep when
1275/// it has more than one, clip otherwise -- except that a text with **no
1276/// content line at all** (`"\n"`) also takes keep.
1277///
1278/// Clip preserves the final break of the last content line, and a block
1279/// with no content line has none, so `k: |` followed by one empty line
1280/// read back as the empty string and the value was lost on the first
1281/// round trip (#383). Under keep the empty line is the value.
1282///
1283/// One function for the three block writers: the rule had three copies
1284/// and the no-content case was missing from all of them.
1285fn block_chomping(s: &str) -> &'static str {
1286    if !s.ends_with('\n') {
1287        "-"
1288    } else if s.ends_with("\n\n") || s.lines().all(str::is_empty) {
1289        "+"
1290    } else {
1291        ""
1292    }
1293}
1294
1295/// Begin an entry's line: a line break, unless the output already ends
1296/// with one, then the indentation.
1297///
1298/// A block scalar closes its own last line, so a break written blindly
1299/// after one opens a blank line. Under clip chomping that was cosmetic;
1300/// under keep chomping (`|+`) the parser keeps the blank line, and a
1301/// keep-chomped value grew by one newline per round trip whenever a
1302/// sibling entry followed it (#385).
1303fn start_line(output: &mut String, indent_spaces: usize) {
1304    if !output.ends_with('\n') {
1305        output.push('\n');
1306    }
1307    write_indent(output, indent_spaces);
1308}
1309
1310fn write_block_scalar(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1311    let chomping = block_chomping(s);
1312
1313    output.push('|');
1314    output.push_str(&block_scalar_indent_indicator(s, config));
1315    output.push_str(chomping);
1316
1317    write_block_scalar_body(output, s, indent, config);
1318
1319    // `str::lines()` never yields an extra empty element for the string's
1320    // *final* line terminator, but every other trailing blank line DOES
1321    // get its own element (and so its own newline from the loop above).
1322    // That means the body loop always emits exactly one newline fewer
1323    // than `s` actually ends with, regardless of how many trailing
1324    // newlines there are: for `"text\n"` the loop emits none after
1325    // "text" (`.lines()` is just `["text"]`), for `"text\n\n"` it emits
1326    // one (`["text", ""]`), for `"text\n\n\n"` it emits two
1327    // (`["text", "", ""]`), and so on -- one behind `s`'s own count every
1328    // time. So exactly one more newline (never a count derived from `s`)
1329    // closes the gap.
1330    //
1331    // The previous version pushed `s.len() - s.trim_end_matches('\n').len()`
1332    // newlines here -- the *full* trailing-newline count -- which double
1333    // counted every trailing newline past the first and grew the string by
1334    // one extra `\n` on every serialize/parse round trip.
1335    if s.ends_with('\n') {
1336        output.push('\n');
1337    }
1338}
1339
1340/// Whether a value can be safely rendered inside an `Auto`-mode flow
1341/// collection. A flow collection may only contain scalars and other flow
1342/// collections — a nested *block* collection would produce invalid YAML
1343/// (`[a, - x]`). So in `Auto` mode the whole subtree must stay within the
1344/// flow threshold for any ancestor to flow; otherwise we fall back to block.
1345///
1346/// `Tagged` values are conservatively treated as block-only: they carry
1347/// anchors, custom tags, and the internal block-scalar/anchor magic tags that
1348/// have no valid flow representation here.
1349fn auto_flow_eligible(value: &Value, config: &SerializerConfig) -> bool {
1350    match value {
1351        Value::Sequence(s) => {
1352            s.len() <= config.flow_threshold && s.iter().all(|v| auto_flow_eligible(v, config))
1353        }
1354        Value::Mapping(m) => {
1355            m.len() <= config.flow_threshold && m.iter().all(|(_, v)| auto_flow_eligible(v, config))
1356        }
1357        Value::Tagged(_) => false,
1358        _ => true,
1359    }
1360}
1361
1362/// Decide whether a collection of `len` items holding `values` should render
1363/// in flow style under the active `config`.
1364fn use_flow<'a, I>(len: usize, values: impl Fn() -> I, config: &SerializerConfig) -> bool
1365where
1366    I: Iterator<Item = &'a Value>,
1367{
1368    match config.flow_style {
1369        FlowStyle::Block => false,
1370        FlowStyle::Flow => true,
1371        FlowStyle::Auto => {
1372            len <= config.flow_threshold && values().all(|v| auto_flow_eligible(v, config))
1373        }
1374    }
1375}
1376
1377fn write_sequence(
1378    output: &mut String,
1379    seq: &Sequence,
1380    indent: usize,
1381    is_root: bool,
1382    config: &SerializerConfig,
1383    depth: usize,
1384) -> Result<()> {
1385    if seq.is_empty() {
1386        output.push_str("[]");
1387        return Ok(());
1388    }
1389
1390    if use_flow(seq.len(), || seq.iter(), config) {
1391        return write_flow_sequence(output, seq, config, depth);
1392    }
1393
1394    for (i, value) in seq.iter().enumerate() {
1395        if i > 0 || !is_root {
1396            start_line(output, config.indent * indent);
1397        }
1398        output.push('-');
1399
1400        match value {
1401            Value::Mapping(m) if !m.is_empty() => {
1402                // The item's first key shares the dash's line, so the dash
1403                // always takes its space here whatever the *value* looks
1404                // like — `- key: 1` and `- key:` alike.
1405                output.push(' ');
1406                for (j, (k, v)) in m.iter().enumerate() {
1407                    if j > 0 {
1408                        start_line(output, config.indent * (indent + 1));
1409                    }
1410                    write_key_string(output, k, indent + 1, config);
1411                    output.push(':');
1412                    if indicator_takes_a_space(v) {
1413                        output.push(' ');
1414                    }
1415                    // `compact_list_indent`: a sequence value starts at its
1416                    // own key's indentation (`indent + 1`, matching `k`'s
1417                    // own column) rather than one level deeper — the same
1418                    // rule `write_mapping` applies, extended to a mapping
1419                    // that is itself a sequence item. Every other
1420                    // block-layout value (a mapping, or a sequence with the
1421                    // option off) still gets the extra level.
1422                    let next_indent = if needs_block_layout(v) {
1423                        if config.compact_list_indent && matches!(v, Value::Sequence(_)) {
1424                            indent + 1
1425                        } else {
1426                            indent + 2
1427                        }
1428                    } else {
1429                        indent + 1
1430                    };
1431                    write_value(output, v, next_indent, false, config, depth + 1)?;
1432                }
1433            }
1434            Value::Sequence(inner) if config.compact_list_indent && !inner.is_empty() => {
1435                // `compact_list_indent`: a sequence item that is itself a
1436                // sequence is written inline (`- - a`), with the nested
1437                // dash sharing this item's own dash's line the same way a
1438                // nested mapping's first key does above. Continuation
1439                // elements align with that nested dash (`indent + 1`).
1440                output.push(' ');
1441                write_sequence(output, inner, indent + 1, true, config, depth + 1)?;
1442            }
1443            _ => {
1444                if indicator_takes_a_space(value) {
1445                    output.push(' ');
1446                }
1447                // A block collection item nests one level under the dash.
1448                // A scalar item uses the indent for one thing, a block
1449                // scalar's body, which belongs `config.indent` columns past
1450                // the dash -- the parent node's indentation the block's
1451                // indentation indicator counts from -- so it takes this
1452                // sequence's own level. One level deeper put the body four
1453                // columns past the dash under a `|2` indicator, and the
1454                // surplus read back as content (#387).
1455                let item_indent = if needs_block_layout(value) {
1456                    indent + 1
1457                } else {
1458                    indent
1459                };
1460                write_value(output, value, item_indent, false, config, depth + 1)?;
1461            }
1462        }
1463    }
1464    Ok(())
1465}
1466
1467/// Whether the indicator introducing `value` — the `:` after a key, or a
1468/// sequence item's `-` — must be followed by a space.
1469///
1470/// An inline scalar needs one (`key: 1`, `- 1`). A block collection does not:
1471/// it begins on the *next* line, so the space would be left dangling at the
1472/// end of this one. That is invisible, it re-parses identically, and it is
1473/// exactly what `git diff --check` and `yamllint`'s `trailing-spaces` reject.
1474///
1475/// The exception is an anchor-wrapped block value, which renders as
1476/// `&idNNN\n  ...`: the `&` is on *this* line, so the space is real
1477/// separation rather than leftovers. A regular (user) tag is the same
1478/// story: `!tag\n  ...` also has visible text — the tag name — on this
1479/// line, so the space is real separation there too.
1480///
1481/// [`write_mapping`] has always applied this rule; [`write_sequence`] carried
1482/// its own copy of the key-writing and did not, which is the whole of the bug
1483/// this function exists to stop recurring. One rule, one place, both callers.
1484fn indicator_takes_a_space(value: &Value) -> bool {
1485    !needs_block_layout(value)
1486        || matches!(
1487            value,
1488            Value::Tagged(t) if t.tag().as_str() == crate::fmt::MAGIC_ANCHOR_DEF
1489                || !t.tag().as_str().starts_with("__noya_")
1490        )
1491}
1492
1493/// Whether a value needs block-style layout (indented on the line after `:`)
1494/// rather than inline scalar layout. Anchor-wrapped block collections must be
1495/// treated like the inner collection; a regular (user) tag likewise needs
1496/// block layout exactly when *its* payload does (a tagged mapping/sequence
1497/// under a key must be indented one level deeper, the same as an untagged
1498/// one — see [`write_value`]'s `Value::Tagged` arm).
1499fn needs_block_layout(v: &Value) -> bool {
1500    match v {
1501        Value::Mapping(m) => !m.is_empty(),
1502        Value::Sequence(s) => !s.is_empty(),
1503        Value::Tagged(t) if t.tag().as_str() == crate::fmt::MAGIC_ANCHOR_DEF => {
1504            if let Value::Sequence(seq) = t.value() {
1505                if seq.len() == 2 {
1506                    return needs_block_layout(&seq[1]);
1507                }
1508            }
1509            false
1510        }
1511        Value::Tagged(t) if !t.tag().as_str().starts_with("__noya_") => {
1512            needs_block_layout(t.value())
1513        }
1514        _ => false,
1515    }
1516}
1517
1518fn write_mapping(
1519    output: &mut String,
1520    map: &Mapping,
1521    indent: usize,
1522    is_root: bool,
1523    config: &SerializerConfig,
1524    depth: usize,
1525) -> Result<()> {
1526    if map.is_empty() {
1527        output.push_str("{}");
1528        return Ok(());
1529    }
1530
1531    if use_flow(map.len(), || map.iter().map(|(_, v)| v), config) {
1532        return write_flow_mapping(output, map, config, depth);
1533    }
1534
1535    for (i, (key, value)) in map.iter().enumerate() {
1536        if i > 0 || !is_root {
1537            start_line(output, config.indent * indent);
1538        }
1539        write_key_string(output, key, indent, config);
1540
1541        output.push(':');
1542        if indicator_takes_a_space(value) {
1543            output.push(' ');
1544        }
1545        let next_indent = if needs_block_layout(value) {
1546            // `compact_list_indent`: when on, sequence values
1547            // under a mapping key align with the key column
1548            // instead of being bumped one indent level deeper.
1549            // This is the visual style preferred by some style
1550            // guides (Kubernetes manifests, GitHub Actions
1551            // workflows). Mappings and other non-sequence block
1552            // values keep the standard indent.
1553            if config.compact_list_indent && matches!(value, Value::Sequence(_)) {
1554                indent
1555            } else {
1556                indent + 1
1557            }
1558        } else {
1559            indent
1560        };
1561        write_value(output, value, next_indent, false, config, depth + 1)?;
1562    }
1563    Ok(())
1564}
1565
1566fn write_internal_tag(
1567    output: &mut String,
1568    tag: &str,
1569    value: &Value,
1570    indent: usize,
1571    is_root: bool,
1572    config: &SerializerConfig,
1573    depth: usize,
1574) -> Result<()> {
1575    match tag {
1576        crate::fmt::MAGIC_FLOW_SEQ => {
1577            if let Value::Sequence(seq) = value {
1578                write_flow_sequence(output, seq, config, depth)?;
1579            } else {
1580                write_value(output, value, indent, is_root, config, depth)?;
1581            }
1582        }
1583        crate::fmt::MAGIC_FLOW_MAP => {
1584            if let Value::Mapping(map) = value {
1585                write_flow_mapping(output, map, config, depth)?;
1586            } else {
1587                write_value(output, value, indent, is_root, config, depth)?;
1588            }
1589        }
1590        crate::fmt::MAGIC_LIT_STR => {
1591            if let Value::String(s) = value {
1592                write_literal_block(output, s, indent, config);
1593            } else {
1594                write_value(output, value, indent, is_root, config, depth)?;
1595            }
1596        }
1597        crate::fmt::MAGIC_FOLD_STR => {
1598            if let Value::String(s) = value {
1599                write_folded_block(output, s, indent, config);
1600            } else {
1601                write_value(output, value, indent, is_root, config, depth)?;
1602            }
1603        }
1604        crate::fmt::MAGIC_COMMENTED => {
1605            // value is a sequence [inner_value, comment_string]
1606            if let Value::Sequence(seq) = value {
1607                if seq.len() == 2 {
1608                    write_value(output, &seq[0], indent, is_root, config, depth)?;
1609                    if let Value::String(comment) = &seq[1] {
1610                        output.push_str(" # ");
1611                        output.push_str(comment);
1612                    }
1613                } else {
1614                    write_value(output, value, indent, is_root, config, depth)?;
1615                }
1616            } else {
1617                write_value(output, value, indent, is_root, config, depth)?;
1618            }
1619        }
1620        crate::fmt::MAGIC_SPACE_AFTER => {
1621            write_value(output, value, indent, is_root, config, depth)?;
1622            output.push('\n');
1623        }
1624        crate::fmt::MAGIC_ANCHOR_DEF => {
1625            // value is a sequence [String(id), inner_value]. Emit "&id" before
1626            // the inner value. For block collections the inner starts on a new
1627            // line; for scalars it follows on the same line.
1628            if let Value::Sequence(seq) = value {
1629                if seq.len() == 2 {
1630                    if let Value::String(id) = &seq[0] {
1631                        let inner = &seq[1];
1632                        output.push('&');
1633                        output.push_str(id);
1634                        match inner {
1635                            Value::Mapping(m) if !m.is_empty() => {
1636                                output.push('\n');
1637                                write_indent(output, config.indent * indent);
1638                                // `is_root = true` suppresses the leading newline
1639                                // inside write_mapping so the anchor line and
1640                                // the first key are correctly adjacent.
1641                                write_mapping(output, m, indent, true, config, depth + 1)?;
1642                            }
1643                            Value::Sequence(s) if !s.is_empty() => {
1644                                output.push('\n');
1645                                write_indent(output, config.indent * indent);
1646                                write_sequence(output, s, indent, true, config, depth + 1)?;
1647                            }
1648                            _ => {
1649                                output.push(' ');
1650                                write_value(output, inner, indent, false, config, depth + 1)?;
1651                            }
1652                        }
1653                    }
1654                }
1655            }
1656        }
1657        crate::fmt::MAGIC_ANCHOR_REF => {
1658            // value is String(id). Emit "*id".
1659            if let Value::String(id) = value {
1660                output.push('*');
1661                output.push_str(id);
1662            }
1663        }
1664        _ => {
1665            // Unknown internal tag — fall through to regular output
1666            write_value(output, value, indent, is_root, config, depth)?;
1667        }
1668    }
1669    Ok(())
1670}
1671
1672fn write_flow_sequence(
1673    output: &mut String,
1674    seq: &Sequence,
1675    config: &SerializerConfig,
1676    depth: usize,
1677) -> Result<()> {
1678    output.push('[');
1679    for (i, value) in seq.iter().enumerate() {
1680        if i > 0 {
1681            output.push_str(", ");
1682        }
1683        write_value(output, value, 0, false, config, depth + 1)?;
1684    }
1685    output.push(']');
1686    Ok(())
1687}
1688
1689fn write_flow_mapping(
1690    output: &mut String,
1691    map: &Mapping,
1692    config: &SerializerConfig,
1693    depth: usize,
1694) -> Result<()> {
1695    output.push('{');
1696    for (i, (key, value)) in map.iter().enumerate() {
1697        if i > 0 {
1698            output.push_str(", ");
1699        }
1700        write_key_string(output, key, 0, config);
1701        output.push_str(": ");
1702        write_value(output, value, 0, false, config, depth + 1)?;
1703    }
1704    output.push('}');
1705    Ok(())
1706}
1707
1708fn write_literal_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1709    let chomping = block_chomping(s);
1710
1711    output.push('|');
1712    output.push_str(&block_scalar_indent_indicator(s, config));
1713    output.push_str(chomping);
1714
1715    write_block_scalar_body(output, s, indent, config);
1716}
1717
1718fn write_folded_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1719    let chomping = block_chomping(s);
1720
1721    output.push('>');
1722    output.push_str(&block_scalar_indent_indicator(s, config));
1723    output.push_str(chomping);
1724
1725    write_block_scalar_body(output, s, indent, config);
1726}
1727
1728/// Serialize an iterable of values as a multi-document YAML
1729/// string with `---` document-start markers between each.
1730///
1731/// # Errors
1732///
1733/// All variants documented on [`to_string`]; the first failing
1734/// document short-circuits and returns its error — earlier
1735/// documents are not emitted.
1736///
1737/// # Examples
1738///
1739/// ```rust
1740/// let docs = vec![1, 2, 3];
1741/// let yaml = noyalib::to_string_multi(&docs).unwrap();
1742/// assert!(yaml.contains("---"));
1743/// ```
1744pub fn to_string_multi<T: serde_core::Serialize>(values: &[T]) -> Result<String> {
1745    to_string_multi_with_config(values, &SerializerConfig::default())
1746}
1747
1748/// Serialize an iterable of values as a multi-document YAML
1749/// string with a custom [`SerializerConfig`].
1750///
1751/// # Errors
1752///
1753/// All variants documented on [`to_string_with_config`].
1754pub fn to_string_multi_with_config<T: serde_core::Serialize>(
1755    values: &[T],
1756    config: &SerializerConfig,
1757) -> Result<String> {
1758    let mut output = String::new();
1759    for (i, value) in values.iter().enumerate() {
1760        if i > 0 {
1761            output.push('\n');
1762        }
1763        output.push_str("---\n");
1764        let v = to_value(value)?;
1765        write_value(&mut output, &v, 0, true, config, 0)?;
1766        output.push('\n');
1767    }
1768    Ok(output)
1769}
1770
1771/// Serialize multiple values as multi-document YAML to a writer.
1772///
1773/// # Errors
1774///
1775/// Returns an error if any value cannot be serialized or writing fails.
1776#[cfg(feature = "std")]
1777pub fn to_writer_multi<W, T>(writer: W, values: &[T]) -> Result<()>
1778where
1779    W: std::io::Write,
1780    T: serde_core::Serialize,
1781{
1782    to_writer_multi_with_config(writer, values, &SerializerConfig::default())
1783}
1784
1785/// Serialize multiple values as multi-document YAML to a writer with custom
1786/// configuration.
1787///
1788/// # Errors
1789///
1790/// Returns an error if any value cannot be serialized or writing fails.
1791#[cfg(feature = "std")]
1792pub fn to_writer_multi_with_config<W, T>(
1793    writer: W,
1794    values: &[T],
1795    config: &SerializerConfig,
1796) -> Result<()>
1797where
1798    W: std::io::Write,
1799    T: serde_core::Serialize,
1800{
1801    let s = to_string_multi_with_config(values, config)?;
1802    let mut writer = writer;
1803    writer.write_all(s.as_bytes())?;
1804    Ok(())
1805}
1806
1807/// A YAML serializer.
1808#[derive(Debug, Copy, Clone)]
1809pub struct Serializer;
1810
1811impl serde_core::ser::Serializer for Serializer {
1812    type Ok = Value;
1813    type Error = Error;
1814
1815    type SerializeSeq = SerializeSeq;
1816    type SerializeTuple = SerializeSeq;
1817    type SerializeTupleStruct = SerializeSeq;
1818    type SerializeTupleVariant = SerializeTupleVariant;
1819    type SerializeMap = SerializeMap;
1820    type SerializeStruct = SerializeMap;
1821    type SerializeStructVariant = SerializeStructVariant;
1822
1823    fn serialize_bool(self, v: bool) -> Result<Value> {
1824        Ok(Value::Bool(v))
1825    }
1826
1827    fn serialize_i8(self, v: i8) -> Result<Value> {
1828        self.serialize_i64(i64::from(v))
1829    }
1830
1831    fn serialize_i16(self, v: i16) -> Result<Value> {
1832        self.serialize_i64(i64::from(v))
1833    }
1834
1835    fn serialize_i32(self, v: i32) -> Result<Value> {
1836        self.serialize_i64(i64::from(v))
1837    }
1838
1839    fn serialize_i64(self, v: i64) -> Result<Value> {
1840        Ok(Value::Number(Number::Integer(v)))
1841    }
1842
1843    fn serialize_u8(self, v: u8) -> Result<Value> {
1844        self.serialize_i64(i64::from(v))
1845    }
1846
1847    fn serialize_u16(self, v: u16) -> Result<Value> {
1848        self.serialize_i64(i64::from(v))
1849    }
1850
1851    fn serialize_u32(self, v: u32) -> Result<Value> {
1852        self.serialize_i64(i64::from(v))
1853    }
1854
1855    fn serialize_u64(self, v: u64) -> Result<Value> {
1856        if let Ok(v) = i64::try_from(v) {
1857            return Ok(Value::Number(Number::Integer(v)));
1858        }
1859        // Values above `i64::MAX` require the `lossless-u64` feature.
1860        // Without it the `Number::Unsigned` variant does not exist and
1861        // there is no lossless representation to fall back to — return
1862        // an explicit serialise-time error so callers can surface the
1863        // limit to their users.
1864        #[cfg(feature = "lossless-u64")]
1865        {
1866            Ok(Value::Number(Number::Unsigned(v)))
1867        }
1868        #[cfg(not(feature = "lossless-u64"))]
1869        {
1870            Err(Error::Serialize(format!(
1871                "u64 value {v} exceeds i64::MAX and cannot be represented losslessly; \
1872                 enable the `lossless-u64` Cargo feature to opt in to unsigned integer support"
1873            )))
1874        }
1875    }
1876
1877    fn serialize_f32(self, v: f32) -> Result<Value> {
1878        self.serialize_f64(f64::from(v))
1879    }
1880
1881    fn serialize_f64(self, v: f64) -> Result<Value> {
1882        Ok(Value::Number(Number::Float(v)))
1883    }
1884
1885    fn serialize_char(self, v: char) -> Result<Value> {
1886        self.serialize_str(&v.to_string())
1887    }
1888
1889    fn serialize_str(self, v: &str) -> Result<Value> {
1890        Ok(Value::String(v.to_owned()))
1891    }
1892
1893    fn serialize_bytes(self, v: &[u8]) -> Result<Value> {
1894        // YAML 1.2.2 §10.4: byte buffers serialise as a `!!binary`
1895        // tagged scalar carrying the RFC 4648 base64 encoding of
1896        // the payload. This is the round-trip partner of the
1897        // `deserialize_bytes` path that recognises `!!binary` and
1898        // base64-decodes on demand. Holds for any `serde_bytes`
1899        // wrapper (`ByteBuf`, `Bytes`) and any `&[u8]` /
1900        // `Vec<u8>`-shaped target the caller annotates with
1901        // `#[serde(with = "serde_bytes")]`.
1902        let encoded = crate::base64::encode(v);
1903        Ok(Value::Tagged(Box::new(TaggedValue::new(
1904            Tag::new("!!binary"),
1905            Value::String(encoded),
1906        ))))
1907    }
1908
1909    fn serialize_none(self) -> Result<Value> {
1910        Ok(Value::Null)
1911    }
1912
1913    fn serialize_some<T>(self, value: &T) -> Result<Value>
1914    where
1915        T: ?Sized + serde_core::Serialize,
1916    {
1917        value.serialize(self)
1918    }
1919
1920    fn serialize_unit(self) -> Result<Value> {
1921        Ok(Value::Null)
1922    }
1923
1924    fn serialize_unit_struct(self, _name: &'static str) -> Result<Value> {
1925        self.serialize_unit()
1926    }
1927
1928    fn serialize_unit_variant(
1929        self,
1930        _name: &'static str,
1931        _variant_index: u32,
1932        variant: &'static str,
1933    ) -> Result<Value> {
1934        self.serialize_str(variant)
1935    }
1936
1937    fn serialize_newtype_struct<T>(self, name: &'static str, value: &T) -> Result<Value>
1938    where
1939        T: ?Sized + serde_core::Serialize,
1940    {
1941        // Intercept formatting hint magic names
1942        match name {
1943            crate::fmt::MAGIC_FLOW_SEQ
1944            | crate::fmt::MAGIC_FLOW_MAP
1945            | crate::fmt::MAGIC_LIT_STR
1946            | crate::fmt::MAGIC_FOLD_STR
1947            | crate::fmt::MAGIC_SPACE_AFTER => {
1948                let inner = value.serialize(Self)?;
1949                Ok(Value::Tagged(Box::new(TaggedValue::new(
1950                    Tag::new(name),
1951                    inner,
1952                ))))
1953            }
1954            crate::fmt::MAGIC_COMMENTED => {
1955                // value is a tuple (inner_value, comment_string)
1956                let inner = value.serialize(Self)?;
1957                Ok(Value::Tagged(Box::new(TaggedValue::new(
1958                    Tag::new(name),
1959                    inner,
1960                ))))
1961            }
1962            crate::fmt::MAGIC_ANCHOR_DEF | crate::fmt::MAGIC_ANCHOR_REF => {
1963                // ANCHOR_DEF: value serializes as Sequence([String(id), inner]).
1964                // ANCHOR_REF: value serializes as String(id).
1965                let inner = value.serialize(Self)?;
1966                Ok(Value::Tagged(Box::new(TaggedValue::new(
1967                    Tag::new(name),
1968                    inner,
1969                ))))
1970            }
1971            crate::fmt::MAGIC_TAGGED => {
1972                // `TaggedValue::serialize`'s wire form: a single-entry map
1973                // keyed by the tag string (#350). The marker name, not the
1974                // map's shape, is what rebuilds `Value::Tagged` here, so a
1975                // genuine one-entry mapping keyed by a `!`-string reaches
1976                // `SerializeMap::end` below and stays a mapping (#377).
1977                match value.serialize(Self)? {
1978                    Value::Mapping(map) if map.len() == 1 => {
1979                        let (tag, inner) = map
1980                            .into_iter()
1981                            .next()
1982                            .expect("length checked to be exactly one entry");
1983                        Ok(Value::Tagged(Box::new(TaggedValue::new(
1984                            Tag::new(tag),
1985                            inner,
1986                        ))))
1987                    }
1988                    other => Ok(other),
1989                }
1990            }
1991            _ => value.serialize(self),
1992        }
1993    }
1994
1995    fn serialize_newtype_variant<T>(
1996        self,
1997        _name: &'static str,
1998        _variant_index: u32,
1999        variant: &'static str,
2000        value: &T,
2001    ) -> Result<Value>
2002    where
2003        T: ?Sized + serde_core::Serialize,
2004    {
2005        let mut map = Mapping::new();
2006        let _ = map.insert(variant.to_owned(), value.serialize(Self)?);
2007        Ok(Value::Mapping(map))
2008    }
2009
2010    fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq> {
2011        Ok(SerializeSeq {
2012            vec: Vec::with_capacity(len.unwrap_or(0)),
2013        })
2014    }
2015
2016    fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple> {
2017        self.serialize_seq(Some(len))
2018    }
2019
2020    fn serialize_tuple_struct(
2021        self,
2022        _name: &'static str,
2023        len: usize,
2024    ) -> Result<Self::SerializeTupleStruct> {
2025        self.serialize_seq(Some(len))
2026    }
2027
2028    fn serialize_tuple_variant(
2029        self,
2030        _name: &'static str,
2031        _variant_index: u32,
2032        variant: &'static str,
2033        len: usize,
2034    ) -> Result<Self::SerializeTupleVariant> {
2035        Ok(SerializeTupleVariant {
2036            name: variant.to_owned(),
2037            vec: Vec::with_capacity(len),
2038        })
2039    }
2040
2041    fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap> {
2042        Ok(SerializeMap {
2043            map: Mapping::new(),
2044            key: None,
2045        })
2046    }
2047
2048    fn serialize_struct(self, _name: &'static str, len: usize) -> Result<Self::SerializeStruct> {
2049        self.serialize_map(Some(len))
2050    }
2051
2052    fn serialize_struct_variant(
2053        self,
2054        _name: &'static str,
2055        _variant_index: u32,
2056        variant: &'static str,
2057        _len: usize,
2058    ) -> Result<Self::SerializeStructVariant> {
2059        Ok(SerializeStructVariant {
2060            name: variant.to_owned(),
2061            map: Mapping::new(),
2062        })
2063    }
2064}
2065
2066/// Serializer for sequences.
2067#[derive(Debug)]
2068pub struct SerializeSeq {
2069    vec: Vec<Value>,
2070}
2071
2072impl serde_core::ser::SerializeSeq for SerializeSeq {
2073    type Ok = Value;
2074    type Error = Error;
2075
2076    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2077    where
2078        T: ?Sized + serde_core::Serialize,
2079    {
2080        self.vec.push(value.serialize(Serializer)?);
2081        Ok(())
2082    }
2083
2084    fn end(self) -> Result<Value> {
2085        Ok(Value::Sequence(self.vec))
2086    }
2087}
2088
2089impl serde_core::ser::SerializeTuple for SerializeSeq {
2090    type Ok = Value;
2091    type Error = Error;
2092
2093    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2094    where
2095        T: ?Sized + serde_core::Serialize,
2096    {
2097        serde_core::ser::SerializeSeq::serialize_element(self, value)
2098    }
2099
2100    fn end(self) -> Result<Value> {
2101        serde_core::ser::SerializeSeq::end(self)
2102    }
2103}
2104
2105impl serde_core::ser::SerializeTupleStruct for SerializeSeq {
2106    type Ok = Value;
2107    type Error = Error;
2108
2109    fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2110    where
2111        T: ?Sized + serde_core::Serialize,
2112    {
2113        serde_core::ser::SerializeSeq::serialize_element(self, value)
2114    }
2115
2116    fn end(self) -> Result<Value> {
2117        serde_core::ser::SerializeSeq::end(self)
2118    }
2119}
2120
2121/// Serializer for tuple variants.
2122#[derive(Debug)]
2123pub struct SerializeTupleVariant {
2124    name: String,
2125    vec: Vec<Value>,
2126}
2127
2128impl serde_core::ser::SerializeTupleVariant for SerializeTupleVariant {
2129    type Ok = Value;
2130    type Error = Error;
2131
2132    fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2133    where
2134        T: ?Sized + serde_core::Serialize,
2135    {
2136        self.vec.push(value.serialize(Serializer)?);
2137        Ok(())
2138    }
2139
2140    fn end(self) -> Result<Value> {
2141        let mut map = Mapping::new();
2142        let _ = map.insert(self.name, Value::Sequence(self.vec));
2143        Ok(Value::Mapping(map))
2144    }
2145}
2146
2147/// Serializer for maps.
2148#[derive(Debug)]
2149pub struct SerializeMap {
2150    map: Mapping,
2151    key: Option<String>,
2152}
2153
2154impl serde_core::ser::SerializeMap for SerializeMap {
2155    type Ok = Value;
2156    type Error = Error;
2157
2158    fn serialize_key<T>(&mut self, key: &T) -> Result<()>
2159    where
2160        T: ?Sized + serde_core::Serialize,
2161    {
2162        let key_value = key.serialize(Serializer)?;
2163        let key_str = match key_value {
2164            Value::String(s) => s,
2165            Value::Number(Number::Integer(n)) => n.to_string(),
2166            #[cfg(feature = "lossless-u64")]
2167            Value::Number(Number::Unsigned(n)) => n.to_string(),
2168            Value::Bool(b) => b.to_string(),
2169            _ => return Err(Error::Serialize("map key must be a string".to_string())),
2170        };
2171        self.key = Some(key_str);
2172        Ok(())
2173    }
2174
2175    fn serialize_value<T>(&mut self, value: &T) -> Result<()>
2176    where
2177        T: ?Sized + serde_core::Serialize,
2178    {
2179        let key = self
2180            .key
2181            .take()
2182            .ok_or_else(|| Error::Serialize("missing key".to_string()))?;
2183        let _ = self.map.insert(key, value.serialize(Serializer)?);
2184        Ok(())
2185    }
2186
2187    fn end(self) -> Result<Value> {
2188        Ok(Value::Mapping(self.map))
2189    }
2190}
2191
2192impl serde_core::ser::SerializeStruct for SerializeMap {
2193    type Ok = Value;
2194    type Error = Error;
2195
2196    fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2197    where
2198        T: ?Sized + serde_core::Serialize,
2199    {
2200        let _ = self
2201            .map
2202            .insert(key.to_owned(), value.serialize(Serializer)?);
2203        Ok(())
2204    }
2205
2206    fn end(self) -> Result<Value> {
2207        Ok(Value::Mapping(self.map))
2208    }
2209}
2210
2211/// Serializer for struct variants.
2212#[derive(Debug)]
2213pub struct SerializeStructVariant {
2214    name: String,
2215    map: Mapping,
2216}
2217
2218impl serde_core::ser::SerializeStructVariant for SerializeStructVariant {
2219    type Ok = Value;
2220    type Error = Error;
2221
2222    fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2223    where
2224        T: ?Sized + serde_core::Serialize,
2225    {
2226        let _ = self
2227            .map
2228            .insert(key.to_owned(), value.serialize(Serializer)?);
2229        Ok(())
2230    }
2231
2232    fn end(self) -> Result<Value> {
2233        let mut map = Mapping::new();
2234        let _ = map.insert(self.name, Value::Mapping(self.map));
2235        Ok(Value::Mapping(map))
2236    }
2237}
2238
2239#[cfg(test)]
2240mod tests {
2241    use super::*;
2242    use crate::Value;
2243
2244    #[test]
2245    fn test_serialization_recursion_limit() {
2246        let mut root = Value::Sequence(vec![Value::Null]);
2247        for _ in 0..200 {
2248            root = Value::Sequence(vec![root]);
2249        }
2250
2251        let config = SerializerConfig::default().max_depth(128);
2252        let result = to_string_with_config(&root, &config);
2253
2254        match result {
2255            Err(Error::RecursionLimitExceeded { depth }) => assert!(depth > 128),
2256            _ => panic!("Expected RecursionLimitExceeded error, got {result:?}"),
2257        }
2258    }
2259
2260    // Regression for #84: `flow_style` was stored but never consulted by the
2261    // emit path, so `FlowStyle::Flow` / `Auto` silently produced block output.
2262    #[test]
2263    fn test_flow_style_flow_emits_inline_collections() {
2264        let seq = Value::Sequence(vec![
2265            Value::from(0),
2266            Value::from(1),
2267            Value::from(2),
2268            Value::from(3),
2269            Value::from(4),
2270        ]);
2271        let mut map = Mapping::new();
2272        let _ = map.insert("a", Value::from(1));
2273        let _ = map.insert("b", Value::from(2));
2274        let _ = map.insert("c", Value::from(3));
2275        let map = Value::Mapping(map);
2276
2277        let config = SerializerConfig::new().flow_style(FlowStyle::Flow);
2278        assert_eq!(
2279            to_string_with_config(&seq, &config).unwrap().trim_end(),
2280            "[0, 1, 2, 3, 4]"
2281        );
2282        assert_eq!(
2283            to_string_with_config(&map, &config).unwrap().trim_end(),
2284            "{a: 1, b: 2, c: 3}"
2285        );
2286    }
2287
2288    #[test]
2289    fn test_flow_style_auto_respects_threshold() {
2290        let small = Value::Sequence((0..3).map(Value::from).collect());
2291        let large = Value::Sequence((0..10).map(Value::from).collect());
2292
2293        let config = SerializerConfig::new()
2294            .flow_style(FlowStyle::Auto)
2295            .flow_threshold(4);
2296
2297        // Small collection (<= threshold) flows inline.
2298        assert_eq!(
2299            to_string_with_config(&small, &config).unwrap().trim_end(),
2300            "[0, 1, 2]"
2301        );
2302        // Large collection (> threshold) stays block.
2303        assert!(
2304            to_string_with_config(&large, &config)
2305                .unwrap()
2306                .starts_with("- 0")
2307        );
2308    }
2309
2310    #[test]
2311    fn test_flow_style_auto_falls_back_when_child_exceeds_threshold() {
2312        // Outer has 2 items (<= threshold) but the inner sequence has 10
2313        // (> threshold). Flowing the outer would emit an invalid block child
2314        // inside flow, so Auto must keep the outer in block style.
2315        let inner = Value::Sequence((0..10).map(Value::from).collect());
2316        let outer = Value::Sequence(vec![Value::from(0), inner]);
2317
2318        let config = SerializerConfig::new()
2319            .flow_style(FlowStyle::Auto)
2320            .flow_threshold(4);
2321        let out = to_string_with_config(&outer, &config).unwrap();
2322        assert!(out.starts_with("- 0"), "outer should stay block: {out:?}");
2323    }
2324
2325    #[test]
2326    fn test_flow_style_block_is_default_unchanged() {
2327        let seq = Value::Sequence((0..3).map(Value::from).collect());
2328        // Default config (Block) and the no-config helper both stay block.
2329        assert!(to_string(&seq).unwrap().starts_with("- 0"));
2330        assert!(
2331            to_string_with_config(&seq, &SerializerConfig::new())
2332                .unwrap()
2333                .starts_with("- 0")
2334        );
2335    }
2336}