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                match shorthand_body {
795                    // A tag a `%TAG` directive resolved is held as a
796                    // bare URI with no `!`. Written as it stands it is
797                    // not a tag at all, and the document reads back as a
798                    // plain scalar, so the value is lost. The verbatim
799                    // form carries it without needing the directive.
800                    None => {
801                        output.push_str("!<");
802                        output.push_str(tag_str);
803                        output.push('>');
804                    }
805                    Some(_) if needs_verbatim => {
806                        output.push_str("!<");
807                        output.push_str(&tag_str[1..]);
808                        output.push('>');
809                    }
810                    Some(_) => output.push_str(tag_str),
811                }
812                let inner = tagged.value();
813                if indicator_takes_a_space(inner) {
814                    output.push(' ');
815                }
816                write_value(output, inner, indent, false, config, depth + 1)?;
817            }
818        }
819    }
820    Ok(())
821}
822
823/// Fast check whether a plain scalar would be interpreted as a number by a YAML
824/// parser.  This is intentionally over-inclusive to ensure roundtrip safety —
825/// it's cheaper to quote a few extra strings than to lose data.
826fn looks_like_number(s: &str) -> bool {
827    let bytes = s.as_bytes();
828    if bytes.is_empty() {
829        return false;
830    }
831
832    // YAML special float literals (case variants)
833    if matches!(
834        s,
835        ".inf"
836            | ".Inf"
837            | ".INF"
838            | "+.inf"
839            | "+.Inf"
840            | "+.INF"
841            | "-.inf"
842            | "-.Inf"
843            | "-.INF"
844            | ".nan"
845            | ".NaN"
846            | ".NAN"
847    ) {
848        return true;
849    }
850
851    // Skip any leading signs (yaml-rust2 is permissive with e.g. "++1")
852    let mut i = 0;
853    while i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
854        i += 1;
855    }
856    if i >= bytes.len() {
857        return false;
858    }
859
860    let rest = &bytes[i..];
861
862    // A digit, or "." and a digit (floats like .5), can open a number.
863    // That is the cheap filter; the parser's own resolver has the last
864    // word, so digit-leading text it keeps as a string (`2026-12-31`,
865    // `1.2.3`, `3rd`, `1/2`) is written plain, as the author wrote it.
866    // The resolver sees the text after the signs: a permissive reader
867    // (yaml-rust2 accepts `++1`) may take stacked signs as one, so what
868    // follows them decides.
869    let candidate =
870        rest[0].is_ascii_digit() || (rest[0] == b'.' && rest.len() > 1 && rest[1].is_ascii_digit());
871    candidate && resolves_as_non_string(&s[i..])
872}
873
874/// The parser's verdict on a plain scalar: would it read back as a
875/// number, a boolean, or null rather than a string?
876///
877/// Runs the resolver the loaders and the streaming path share, with the
878/// YAML 1.1 legacy forms enabled (`0`-prefixed octals, sexagesimals) so a
879/// string is quoted whenever any reader configuration would turn it into
880/// something else.
881fn resolves_as_non_string(s: &str) -> bool {
882    !matches!(
883        crate::streaming::resolve_plain_ext(s, false, true, false, true, true, false),
884        crate::streaming::Scalar::Str(_)
885    )
886}
887
888/// A `:` ends a plain scalar only when a space, a tab, a flow indicator,
889/// or the end of the text follows it (YAML 1.2 plain scalars), so
890/// `word:count`, `10:00:00Z`, and `http://` stay plain.
891fn colon_ends_plain(bytes: &[u8], i: usize) -> bool {
892    match bytes.get(i + 1) {
893        None => true,
894        Some(&next) => matches!(next, b' ' | b'\t' | b',' | b'[' | b']' | b'{' | b'}'),
895    }
896}
897
898/// A `#` starts a comment only at the start of the text or after
899/// whitespace, so `a#b` stays plain.
900fn hash_starts_comment(bytes: &[u8], i: usize) -> bool {
901    i == 0 || matches!(bytes[i - 1], b' ' | b'\t')
902}
903
904/// Lookup table: true if the byte can require the string to be quoted.
905/// Covers: control chars (except tab), colon, hash, newline, etc. A colon
906/// or a hash is then judged in context by `colon_ends_plain` and
907/// `hash_starts_comment`.
908static NEEDS_QUOTE_BYTE: [bool; 128] = {
909    let mut t = [false; 128];
910    // Control characters (except tab 0x09)
911    let mut i = 0u8;
912    while i < 0x20 {
913        if i != b'\t' {
914            t[i as usize] = true;
915        }
916        i += 1;
917    }
918    // YAML structural characters
919    t[b':' as usize] = true;
920    t[b'#' as usize] = true;
921    t[b'\n' as usize] = true;
922    t[b'\r' as usize] = true;
923    t[b'\0' as usize] = true;
924    t
925};
926
927/// Characters that require quoting when they appear as the first character.
928static FIRST_CHAR_QUOTE: [bool; 128] = {
929    let mut t = [false; 128];
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[b'%' as usize] = true;
938    t[b'@' as usize] = true;
939    t[b'`' as usize] = true;
940    t[b'{' as usize] = true;
941    t[b'}' as usize] = true;
942    t[b'[' as usize] = true;
943    t[b']' as usize] = true;
944    t[b',' as usize] = true;
945    t[b'?' as usize] = true;
946    t[b'\'' as usize] = true;
947    t[b'"' as usize] = true;
948    t
949};
950
951/// A character only double-quoted style can carry faithfully: CR, NEL
952/// (U+0085), LS (U+2028), PS (U+2029) — and a BOM (U+FEFF). A literal
953/// block scalar normalises `\r` into the block's own line breaks, and
954/// the three Unicode separators pass through plain and single-quoted
955/// styles as raw bytes that 1.1-era parsers (and this crate's own
956/// reader) fold as line breaks, so a round trip changes the string
957/// (#335). A raw BOM is worse: the reader must not accept one inside
958/// a document at all (§5.2), and a string-leading BOM emitted plain
959/// is stream-skipped on re-parse, reinterpreting the rest of the
960/// scalar as markup (found by fuzz_roundtrip).
961///
962/// The C1 block (U+0080 to U+009F, NEL included) and the non-characters
963/// U+FFFE and U+FFFF sit outside `c-printable` (§5.1): this crate's
964/// reader takes them raw, but libyaml-based tools reject the document,
965/// so they force the same style with a hex escape (#379).
966fn needs_double_quoted_escape(s: &str) -> bool {
967    s.chars().any(|c| {
968        matches!(
969            c,
970            '\r' | '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}'
971        ) || ('\u{7f}'..='\u{9f}').contains(&c)
972            || (c < '\u{20}' && c != '\t' && c != '\n')
973    })
974}
975
976/// Write a mapping key. Keys are implicit (`key: value`) in every
977/// form this serializer emits, and an implicit key must fit on one
978/// line — the block scalar styles are not grammar there at all — so
979/// a string the value writer would render as a `|`/`>` block (any
980/// string holding a line break) is written double-quoted with
981/// escapes instead. Found by fuzz_roundtrip: a multi-line key
982/// emitted as a `|-` block produced YAML that no longer parsed
983/// ("expected block mapping key or end").
984fn write_key_string(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
985    if s.contains('\n') {
986        write_double_quoted(output, s);
987    } else {
988        write_string(output, s, indent, config);
989    }
990}
991
992fn write_string(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
993    let bytes = s.as_bytes();
994
995    // Empty string must be quoted
996    if bytes.is_empty() {
997        if config.prefer_single_quotes {
998            output.push_str("''");
999        } else {
1000            output.push_str("\"\"");
1001        }
1002        return;
1003    }
1004
1005    // Force-quote all strings when configured. Single-quoted style has
1006    // no escapes, so a string only double-quoted style can carry still
1007    // falls back regardless of the setting.
1008    if config.quote_all {
1009        if needs_double_quoted_escape(s) {
1010            write_double_quoted(output, s);
1011        } else {
1012            write_single_quoted(output, s);
1013        }
1014        return;
1015    }
1016
1017    // A scalar starting with `...` emitted at the start of a line
1018    // reads back as the document-end marker (explicit-key emission
1019    // places keys at column 0), so it can never go plain — same
1020    // family as the `-` first-byte rule below, which already covers
1021    // `---` (found by fuzz_roundtrip on a `? ...` explicit key).
1022    if s.starts_with("...") {
1023        if needs_double_quoted_escape(s) {
1024            write_double_quoted(output, s);
1025        } else {
1026            write_single_quoted(output, s);
1027        }
1028        return;
1029    }
1030
1031    // Fast path: short ASCII strings that are clearly safe as plain scalars.
1032    // Avoids the full lookup table scan for the majority of mapping keys.
1033    //
1034    // The intent is: short, alnum-bounded, no newline. All four conditions
1035    // below are ANDed — `||` binds looser than `&&`, and an earlier version
1036    // of this guard read `a && b && c && !config.block_scalars ||
1037    // no_newline`, which let *every* newline-free string take the fast path
1038    // regardless of its first byte (`"-"` slipped through unquoted and
1039    // re-parsed as a block sequence entry, not a scalar). The first/last
1040    // alnum checks already exclude every `FIRST_CHAR_QUOTE` member (none of
1041    // them are alphanumeric) and tab (also not alphanumeric), but the
1042    // explicit `FIRST_CHAR_QUOTE` check is kept here too as defense in
1043    // depth against the alnum check alone being loosened later.
1044    if bytes.len() <= 64
1045        && bytes[0].is_ascii_alphanumeric()
1046        && bytes[bytes.len() - 1].is_ascii_alphanumeric()
1047        && bytes.iter().all(|&b| b != b'\n')
1048        && !(bytes[0] < 128 && FIRST_CHAR_QUOTE[bytes[0] as usize])
1049    {
1050        let safe = bytes.iter().all(|&b| {
1051            b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/'
1052        });
1053        if safe
1054            && !matches!(
1055                s,
1056                "true"
1057                    | "false"
1058                    | "null"
1059                    | "~"
1060                    | "True"
1061                    | "False"
1062                    | "TRUE"
1063                    | "FALSE"
1064                    | "Null"
1065                    | "NULL"
1066            )
1067            && !looks_like_number(s)
1068        {
1069            output.push_str(s);
1070            return;
1071        }
1072    }
1073
1074    // Block scalar for multiline strings -- unless the string carries a
1075    // character a block scalar cannot represent: `str::lines` and the
1076    // block's own line breaks erase a `\r` (#335).
1077    if config.block_scalars && !needs_double_quoted_escape(s) {
1078        let newlines = bytes.iter().filter(|&&b| b == b'\n').count();
1079        if newlines >= config.block_scalar_threshold {
1080            write_block_scalar(output, s, indent, config);
1081            return;
1082        }
1083    }
1084
1085    // CR and the Unicode line separators are representable only with
1086    // double-quoted escapes (#335).
1087    if needs_double_quoted_escape(s) {
1088        write_double_quoted(output, s);
1089        return;
1090    }
1091
1092    // Single-pass quoting decision
1093    let mut needs_quotes = false;
1094    let mut has_control = false;
1095
1096    // Check first character
1097    if bytes[0] < 128 && FIRST_CHAR_QUOTE[bytes[0] as usize] {
1098        needs_quotes = true;
1099    }
1100
1101    // A leading or trailing tab must quote. `NEEDS_QUOTE_BYTE` deliberately
1102    // excludes tab so an *interior* tab stays unescaped in a plain scalar,
1103    // but YAML 1.2 still requires quoting when a plain scalar's content
1104    // starts or ends in white space (tab included), or the boundary is
1105    // lost on re-parse.
1106    if bytes[0] == b'\t' || bytes[bytes.len() - 1] == b'\t' {
1107        needs_quotes = true;
1108    }
1109
1110    // Check last character (trailing space)
1111    if bytes[bytes.len() - 1] == b' ' {
1112        needs_quotes = true;
1113    }
1114
1115    // Reserved words
1116    if !needs_quotes {
1117        needs_quotes = matches!(
1118            s,
1119            "true" | "false" | "null" | "~" | "True" | "False" | "TRUE" | "FALSE" | "Null" | "NULL"
1120        ) || looks_like_number(s);
1121    }
1122
1123    // Single pass through interior bytes. A colon or a hash counts only
1124    // where YAML gives it meaning; see `colon_ends_plain` and
1125    // `hash_starts_comment`.
1126    if !needs_quotes {
1127        for (i, &b) in bytes.iter().enumerate() {
1128            if b >= 128 || !NEEDS_QUOTE_BYTE[b as usize] {
1129                continue;
1130            }
1131            if (b == b':' && !colon_ends_plain(bytes, i))
1132                || (b == b'#' && !hash_starts_comment(bytes, i))
1133            {
1134                continue;
1135            }
1136            if b < 0x20 && b != b'\t' {
1137                has_control = true;
1138            }
1139            needs_quotes = true;
1140            // Don't break - we need to know if there are control chars
1141        }
1142    }
1143
1144    if !needs_quotes {
1145        // Plain scalar - zero-copy output
1146        output.push_str(s);
1147        return;
1148    }
1149
1150    if config.prefer_single_quotes && single_quote_safe(s) {
1151        write_single_quoted(output, s);
1152        return;
1153    }
1154
1155    // Use double quotes for all quoted strings
1156    let _ = has_control;
1157    write_double_quoted(output, s);
1158}
1159
1160/// Whether `s` can be represented as a YAML single-quoted scalar with no
1161/// escapes beyond doubling an embedded `'`.
1162///
1163/// Single-quoted style has no escape mechanism at all besides doubling the
1164/// quote character itself — a literal backslash, double quote, `#`, `:`,
1165/// and so on all pass straight through unescaped. What it *cannot* carry is
1166/// a control character (tab, newline, carriage return, and the rest of the
1167/// C0/C1 ranges) or any other non-printable code point: those need one of
1168/// double-quoted style's escape sequences, so a string containing one must
1169/// fall back to double-quoted even when `prefer_single_quotes` is set.
1170fn single_quote_safe(s: &str) -> bool {
1171    !s.chars()
1172        .any(|c| c.is_control() || matches!(c, '\u{fffe}' | '\u{ffff}'))
1173}
1174
1175/// Write a single-quoted string, escaping embedded single quotes.
1176fn write_single_quoted(output: &mut String, s: &str) {
1177    output.push('\'');
1178    for c in s.chars() {
1179        if c == '\'' {
1180            output.push_str("''");
1181        } else {
1182            output.push(c);
1183        }
1184    }
1185    output.push('\'');
1186}
1187
1188/// Write a double-quoted string with bulk-copy between escape points.
1189fn write_double_quoted(output: &mut String, s: &str) {
1190    output.push('"');
1191    let mut start = 0;
1192    for (i, c) in s.char_indices() {
1193        let esc = match c {
1194            '"' => "\\\"",
1195            '\\' => "\\\\",
1196            '\n' => "\\n",
1197            '\r' => "\\r",
1198            '\t' => "\\t",
1199            '\0' => "\\0",
1200            // Named escapes for the non-ASCII line-break characters
1201            // (YAML 1.2 section 5.7): emitted raw they read back as
1202            // line breaks in 1.1-era parsers and in this crate's own
1203            // reader (#335).
1204            '\u{0085}' => "\\N",
1205            '\u{2028}' => "\\L",
1206            '\u{2029}' => "\\P",
1207            // A raw BOM must never reach the output stream — the
1208            // reader rejects one inside a document (§5.2).
1209            '\u{feff}' => "\\uFEFF",
1210            // The two non-characters sit outside `c-printable` (§5.1)
1211            // and have no named escape (#379).
1212            '\u{fffe}' | '\u{ffff}' => {
1213                output.push_str(&s[start..i]);
1214                let _ = write!(output, "\\u{:04X}", c as u32);
1215                start = i + c.len_utf8();
1216                continue;
1217            }
1218            c if (c as u32) < 0x20 || ('\u{7f}'..='\u{9f}').contains(&c) => {
1219                // Other control characters -- C0, DEL and the C1 block
1220                // (#379; NEL is matched above): flush and write the
1221                // two-digit hex escape. A C1 character is two bytes in
1222                // UTF-8, so advance by its width, not by one.
1223                output.push_str(&s[start..i]);
1224                let _ = write!(output, "\\x{:02X}", c as u32);
1225                start = i + c.len_utf8();
1226                continue;
1227            }
1228            _ => continue,
1229        };
1230        output.push_str(&s[start..i]);
1231        output.push_str(esc);
1232        start = i + c.len_utf8();
1233    }
1234    output.push_str(&s[start..]);
1235    output.push('"');
1236}
1237
1238/// Write a string using YAML literal block scalar style (|).
1239/// Write a block scalar's content lines at `indent + 1`.
1240///
1241/// An empty line gets **no** indentation. The block's indent is detected from
1242/// its first non-empty line, so an empty one has nothing to say; writing the
1243/// indent anyway leaves it standing as trailing whitespace on a line that
1244/// holds nothing, which `git diff --check` and `yamllint` reject.
1245///
1246/// This must stay one function. It had three identical copies — `|` auto, `|`
1247/// explicit and `>` — and the empty-line rule was missing from all three, so
1248/// fixing any one of them would have left the other two writing it.
1249fn write_block_scalar_body(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1250    for line in s.lines() {
1251        output.push('\n');
1252        if !line.is_empty() {
1253            write_indent(output, config.indent * (indent + 1));
1254            output.push_str(line);
1255        }
1256    }
1257}
1258
1259/// The explicit indentation indicator digit(s), if the block needs one.
1260///
1261/// YAML 1.2.2 §8.1.1.1: a literal/folded block scalar's indentation is
1262/// normally *auto-detected* from its first non-empty content line. That
1263/// detection breaks when the first content line itself starts with a
1264/// space or a tab — the leading whitespace gets folded into the detected
1265/// indentation, inflating it past what later, less-indented lines carry,
1266/// which a parser then rejects as inconsistent indentation. The fix is an
1267/// explicit indentation indicator between the block style character
1268/// (`|`/`>`) and the chomping indicator, stating the indentation as a
1269/// number of columns *beyond the parent node's own indentation*.
1270///
1271/// [`write_block_scalar_body`] always places content `config.indent`
1272/// columns beyond the indentation this function's caller was handed for
1273/// this value's slot (the parent node's own indentation) — so whenever an
1274/// indicator is needed, its value is exactly `config.indent`, independent
1275/// of nesting depth.
1276fn block_scalar_indent_indicator(s: &str, config: &SerializerConfig) -> String {
1277    let first_content_line = s.lines().find(|line| !line.is_empty());
1278    match first_content_line {
1279        Some(line) if line.starts_with(' ') || line.starts_with('\t') => config.indent.to_string(),
1280        _ => String::new(),
1281    }
1282}
1283
1284/// The chomping indicator for a block scalar holding `s` (YAML 1.2.2
1285/// §8.1.1.2): strip when the text has no trailing line break, keep when
1286/// it has more than one, clip otherwise -- except that a text with **no
1287/// content line at all** (`"\n"`) also takes keep.
1288///
1289/// Clip preserves the final break of the last content line, and a block
1290/// with no content line has none, so `k: |` followed by one empty line
1291/// read back as the empty string and the value was lost on the first
1292/// round trip (#383). Under keep the empty line is the value.
1293///
1294/// One function for the three block writers: the rule had three copies
1295/// and the no-content case was missing from all of them.
1296fn block_chomping(s: &str) -> &'static str {
1297    if !s.ends_with('\n') {
1298        "-"
1299    } else if s.ends_with("\n\n") || s.lines().all(str::is_empty) {
1300        "+"
1301    } else {
1302        ""
1303    }
1304}
1305
1306/// Begin an entry's line: a line break, unless the output already ends
1307/// with one, then the indentation.
1308///
1309/// A block scalar closes its own last line, so a break written blindly
1310/// after one opens a blank line. Under clip chomping that was cosmetic;
1311/// under keep chomping (`|+`) the parser keeps the blank line, and a
1312/// keep-chomped value grew by one newline per round trip whenever a
1313/// sibling entry followed it (#385).
1314fn start_line(output: &mut String, indent_spaces: usize) {
1315    if !output.ends_with('\n') {
1316        output.push('\n');
1317    }
1318    write_indent(output, indent_spaces);
1319}
1320
1321fn write_block_scalar(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1322    let chomping = block_chomping(s);
1323
1324    output.push('|');
1325    output.push_str(&block_scalar_indent_indicator(s, config));
1326    output.push_str(chomping);
1327
1328    write_block_scalar_body(output, s, indent, config);
1329
1330    // `str::lines()` never yields an extra empty element for the string's
1331    // *final* line terminator, but every other trailing blank line DOES
1332    // get its own element (and so its own newline from the loop above).
1333    // That means the body loop always emits exactly one newline fewer
1334    // than `s` actually ends with, regardless of how many trailing
1335    // newlines there are: for `"text\n"` the loop emits none after
1336    // "text" (`.lines()` is just `["text"]`), for `"text\n\n"` it emits
1337    // one (`["text", ""]`), for `"text\n\n\n"` it emits two
1338    // (`["text", "", ""]`), and so on -- one behind `s`'s own count every
1339    // time. So exactly one more newline (never a count derived from `s`)
1340    // closes the gap.
1341    //
1342    // The previous version pushed `s.len() - s.trim_end_matches('\n').len()`
1343    // newlines here -- the *full* trailing-newline count -- which double
1344    // counted every trailing newline past the first and grew the string by
1345    // one extra `\n` on every serialize/parse round trip.
1346    if s.ends_with('\n') {
1347        output.push('\n');
1348    }
1349}
1350
1351/// Whether a value can be safely rendered inside an `Auto`-mode flow
1352/// collection. A flow collection may only contain scalars and other flow
1353/// collections — a nested *block* collection would produce invalid YAML
1354/// (`[a, - x]`). So in `Auto` mode the whole subtree must stay within the
1355/// flow threshold for any ancestor to flow; otherwise we fall back to block.
1356///
1357/// `Tagged` values are conservatively treated as block-only: they carry
1358/// anchors, custom tags, and the internal block-scalar/anchor magic tags that
1359/// have no valid flow representation here.
1360fn auto_flow_eligible(value: &Value, config: &SerializerConfig) -> bool {
1361    match value {
1362        Value::Sequence(s) => {
1363            s.len() <= config.flow_threshold && s.iter().all(|v| auto_flow_eligible(v, config))
1364        }
1365        Value::Mapping(m) => {
1366            m.len() <= config.flow_threshold && m.iter().all(|(_, v)| auto_flow_eligible(v, config))
1367        }
1368        Value::Tagged(_) => false,
1369        _ => true,
1370    }
1371}
1372
1373/// Decide whether a collection of `len` items holding `values` should render
1374/// in flow style under the active `config`.
1375fn use_flow<'a, I>(len: usize, values: impl Fn() -> I, config: &SerializerConfig) -> bool
1376where
1377    I: Iterator<Item = &'a Value>,
1378{
1379    match config.flow_style {
1380        FlowStyle::Block => false,
1381        FlowStyle::Flow => true,
1382        FlowStyle::Auto => {
1383            len <= config.flow_threshold && values().all(|v| auto_flow_eligible(v, config))
1384        }
1385    }
1386}
1387
1388fn write_sequence(
1389    output: &mut String,
1390    seq: &Sequence,
1391    indent: usize,
1392    is_root: bool,
1393    config: &SerializerConfig,
1394    depth: usize,
1395) -> Result<()> {
1396    if seq.is_empty() {
1397        output.push_str("[]");
1398        return Ok(());
1399    }
1400
1401    if use_flow(seq.len(), || seq.iter(), config) {
1402        return write_flow_sequence(output, seq, config, depth);
1403    }
1404
1405    for (i, value) in seq.iter().enumerate() {
1406        if i > 0 || !is_root {
1407            start_line(output, config.indent * indent);
1408        }
1409        output.push('-');
1410
1411        match value {
1412            Value::Mapping(m) if !m.is_empty() => {
1413                // The item's first key shares the dash's line, so the dash
1414                // always takes its space here whatever the *value* looks
1415                // like — `- key: 1` and `- key:` alike.
1416                output.push(' ');
1417                for (j, (k, v)) in m.iter().enumerate() {
1418                    if j > 0 {
1419                        start_line(output, config.indent * (indent + 1));
1420                    }
1421                    write_key_string(output, k, indent + 1, config);
1422                    output.push(':');
1423                    if indicator_takes_a_space(v) {
1424                        output.push(' ');
1425                    }
1426                    // `compact_list_indent`: a sequence value starts at its
1427                    // own key's indentation (`indent + 1`, matching `k`'s
1428                    // own column) rather than one level deeper — the same
1429                    // rule `write_mapping` applies, extended to a mapping
1430                    // that is itself a sequence item. Every other
1431                    // block-layout value (a mapping, or a sequence with the
1432                    // option off) still gets the extra level.
1433                    let next_indent = if needs_block_layout(v) {
1434                        if config.compact_list_indent && matches!(v, Value::Sequence(_)) {
1435                            indent + 1
1436                        } else {
1437                            indent + 2
1438                        }
1439                    } else {
1440                        indent + 1
1441                    };
1442                    write_value(output, v, next_indent, false, config, depth + 1)?;
1443                }
1444            }
1445            Value::Sequence(inner) if config.compact_list_indent && !inner.is_empty() => {
1446                // `compact_list_indent`: a sequence item that is itself a
1447                // sequence is written inline (`- - a`), with the nested
1448                // dash sharing this item's own dash's line the same way a
1449                // nested mapping's first key does above. Continuation
1450                // elements align with that nested dash (`indent + 1`).
1451                output.push(' ');
1452                write_sequence(output, inner, indent + 1, true, config, depth + 1)?;
1453            }
1454            _ => {
1455                if indicator_takes_a_space(value) {
1456                    output.push(' ');
1457                }
1458                // A block collection item nests one level under the dash.
1459                // A scalar item uses the indent for one thing, a block
1460                // scalar's body, which belongs `config.indent` columns past
1461                // the dash -- the parent node's indentation the block's
1462                // indentation indicator counts from -- so it takes this
1463                // sequence's own level. One level deeper put the body four
1464                // columns past the dash under a `|2` indicator, and the
1465                // surplus read back as content (#387).
1466                let item_indent = if needs_block_layout(value) {
1467                    indent + 1
1468                } else {
1469                    indent
1470                };
1471                write_value(output, value, item_indent, false, config, depth + 1)?;
1472            }
1473        }
1474    }
1475    Ok(())
1476}
1477
1478/// Whether the indicator introducing `value` — the `:` after a key, or a
1479/// sequence item's `-` — must be followed by a space.
1480///
1481/// An inline scalar needs one (`key: 1`, `- 1`). A block collection does not:
1482/// it begins on the *next* line, so the space would be left dangling at the
1483/// end of this one. That is invisible, it re-parses identically, and it is
1484/// exactly what `git diff --check` and `yamllint`'s `trailing-spaces` reject.
1485///
1486/// The exception is an anchor-wrapped block value, which renders as
1487/// `&idNNN\n  ...`: the `&` is on *this* line, so the space is real
1488/// separation rather than leftovers. A regular (user) tag is the same
1489/// story: `!tag\n  ...` also has visible text — the tag name — on this
1490/// line, so the space is real separation there too.
1491///
1492/// [`write_mapping`] has always applied this rule; [`write_sequence`] carried
1493/// its own copy of the key-writing and did not, which is the whole of the bug
1494/// this function exists to stop recurring. One rule, one place, both callers.
1495fn indicator_takes_a_space(value: &Value) -> bool {
1496    !needs_block_layout(value)
1497        || matches!(
1498            value,
1499            Value::Tagged(t) if t.tag().as_str() == crate::fmt::MAGIC_ANCHOR_DEF
1500                || !t.tag().as_str().starts_with("__noya_")
1501        )
1502}
1503
1504/// Whether a value needs block-style layout (indented on the line after `:`)
1505/// rather than inline scalar layout. Anchor-wrapped block collections must be
1506/// treated like the inner collection; a regular (user) tag likewise needs
1507/// block layout exactly when *its* payload does (a tagged mapping/sequence
1508/// under a key must be indented one level deeper, the same as an untagged
1509/// one — see [`write_value`]'s `Value::Tagged` arm).
1510fn needs_block_layout(v: &Value) -> bool {
1511    match v {
1512        Value::Mapping(m) => !m.is_empty(),
1513        Value::Sequence(s) => !s.is_empty(),
1514        Value::Tagged(t) if t.tag().as_str() == crate::fmt::MAGIC_ANCHOR_DEF => {
1515            if let Value::Sequence(seq) = t.value() {
1516                if seq.len() == 2 {
1517                    return needs_block_layout(&seq[1]);
1518                }
1519            }
1520            false
1521        }
1522        Value::Tagged(t) if !t.tag().as_str().starts_with("__noya_") => {
1523            needs_block_layout(t.value())
1524        }
1525        _ => false,
1526    }
1527}
1528
1529fn write_mapping(
1530    output: &mut String,
1531    map: &Mapping,
1532    indent: usize,
1533    is_root: bool,
1534    config: &SerializerConfig,
1535    depth: usize,
1536) -> Result<()> {
1537    if map.is_empty() {
1538        output.push_str("{}");
1539        return Ok(());
1540    }
1541
1542    if use_flow(map.len(), || map.iter().map(|(_, v)| v), config) {
1543        return write_flow_mapping(output, map, config, depth);
1544    }
1545
1546    for (i, (key, value)) in map.iter().enumerate() {
1547        if i > 0 || !is_root {
1548            start_line(output, config.indent * indent);
1549        }
1550        write_key_string(output, key, indent, config);
1551
1552        output.push(':');
1553        if indicator_takes_a_space(value) {
1554            output.push(' ');
1555        }
1556        let next_indent = if needs_block_layout(value) {
1557            // `compact_list_indent`: when on, sequence values
1558            // under a mapping key align with the key column
1559            // instead of being bumped one indent level deeper.
1560            // This is the visual style preferred by some style
1561            // guides (Kubernetes manifests, GitHub Actions
1562            // workflows). Mappings and other non-sequence block
1563            // values keep the standard indent.
1564            if config.compact_list_indent && matches!(value, Value::Sequence(_)) {
1565                indent
1566            } else {
1567                indent + 1
1568            }
1569        } else {
1570            indent
1571        };
1572        write_value(output, value, next_indent, false, config, depth + 1)?;
1573    }
1574    Ok(())
1575}
1576
1577fn write_internal_tag(
1578    output: &mut String,
1579    tag: &str,
1580    value: &Value,
1581    indent: usize,
1582    is_root: bool,
1583    config: &SerializerConfig,
1584    depth: usize,
1585) -> Result<()> {
1586    match tag {
1587        crate::fmt::MAGIC_FLOW_SEQ => {
1588            if let Value::Sequence(seq) = value {
1589                write_flow_sequence(output, seq, config, depth)?;
1590            } else {
1591                write_value(output, value, indent, is_root, config, depth)?;
1592            }
1593        }
1594        crate::fmt::MAGIC_FLOW_MAP => {
1595            if let Value::Mapping(map) = value {
1596                write_flow_mapping(output, map, config, depth)?;
1597            } else {
1598                write_value(output, value, indent, is_root, config, depth)?;
1599            }
1600        }
1601        crate::fmt::MAGIC_LIT_STR => {
1602            if let Value::String(s) = value {
1603                write_literal_block(output, s, indent, config);
1604            } else {
1605                write_value(output, value, indent, is_root, config, depth)?;
1606            }
1607        }
1608        crate::fmt::MAGIC_FOLD_STR => {
1609            if let Value::String(s) = value {
1610                write_folded_block(output, s, indent, config);
1611            } else {
1612                write_value(output, value, indent, is_root, config, depth)?;
1613            }
1614        }
1615        crate::fmt::MAGIC_COMMENTED => {
1616            // value is a sequence [inner_value, comment_string]
1617            if let Value::Sequence(seq) = value {
1618                if seq.len() == 2 {
1619                    write_value(output, &seq[0], indent, is_root, config, depth)?;
1620                    if let Value::String(comment) = &seq[1] {
1621                        output.push_str(" # ");
1622                        output.push_str(comment);
1623                    }
1624                } else {
1625                    write_value(output, value, indent, is_root, config, depth)?;
1626                }
1627            } else {
1628                write_value(output, value, indent, is_root, config, depth)?;
1629            }
1630        }
1631        crate::fmt::MAGIC_SPACE_AFTER => {
1632            write_value(output, value, indent, is_root, config, depth)?;
1633            output.push('\n');
1634        }
1635        crate::fmt::MAGIC_ANCHOR_DEF => {
1636            // value is a sequence [String(id), inner_value]. Emit "&id" before
1637            // the inner value. For block collections the inner starts on a new
1638            // line; for scalars it follows on the same line.
1639            if let Value::Sequence(seq) = value {
1640                if seq.len() == 2 {
1641                    if let Value::String(id) = &seq[0] {
1642                        let inner = &seq[1];
1643                        output.push('&');
1644                        output.push_str(id);
1645                        match inner {
1646                            Value::Mapping(m) if !m.is_empty() => {
1647                                output.push('\n');
1648                                write_indent(output, config.indent * indent);
1649                                // `is_root = true` suppresses the leading newline
1650                                // inside write_mapping so the anchor line and
1651                                // the first key are correctly adjacent.
1652                                write_mapping(output, m, indent, true, config, depth + 1)?;
1653                            }
1654                            Value::Sequence(s) if !s.is_empty() => {
1655                                output.push('\n');
1656                                write_indent(output, config.indent * indent);
1657                                write_sequence(output, s, indent, true, config, depth + 1)?;
1658                            }
1659                            _ => {
1660                                output.push(' ');
1661                                write_value(output, inner, indent, false, config, depth + 1)?;
1662                            }
1663                        }
1664                    }
1665                }
1666            }
1667        }
1668        crate::fmt::MAGIC_ANCHOR_REF => {
1669            // value is String(id). Emit "*id".
1670            if let Value::String(id) = value {
1671                output.push('*');
1672                output.push_str(id);
1673            }
1674        }
1675        _ => {
1676            // Unknown internal tag — fall through to regular output
1677            write_value(output, value, indent, is_root, config, depth)?;
1678        }
1679    }
1680    Ok(())
1681}
1682
1683fn write_flow_sequence(
1684    output: &mut String,
1685    seq: &Sequence,
1686    config: &SerializerConfig,
1687    depth: usize,
1688) -> Result<()> {
1689    output.push('[');
1690    for (i, value) in seq.iter().enumerate() {
1691        if i > 0 {
1692            output.push_str(", ");
1693        }
1694        write_value(output, value, 0, false, config, depth + 1)?;
1695    }
1696    output.push(']');
1697    Ok(())
1698}
1699
1700fn write_flow_mapping(
1701    output: &mut String,
1702    map: &Mapping,
1703    config: &SerializerConfig,
1704    depth: usize,
1705) -> Result<()> {
1706    output.push('{');
1707    for (i, (key, value)) in map.iter().enumerate() {
1708        if i > 0 {
1709            output.push_str(", ");
1710        }
1711        write_key_string(output, key, 0, config);
1712        output.push_str(": ");
1713        write_value(output, value, 0, false, config, depth + 1)?;
1714    }
1715    output.push('}');
1716    Ok(())
1717}
1718
1719fn write_literal_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1720    let chomping = block_chomping(s);
1721
1722    output.push('|');
1723    output.push_str(&block_scalar_indent_indicator(s, config));
1724    output.push_str(chomping);
1725
1726    write_block_scalar_body(output, s, indent, config);
1727}
1728
1729fn write_folded_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1730    let chomping = block_chomping(s);
1731
1732    output.push('>');
1733    output.push_str(&block_scalar_indent_indicator(s, config));
1734    output.push_str(chomping);
1735
1736    write_block_scalar_body(output, s, indent, config);
1737}
1738
1739/// Serialize an iterable of values as a multi-document YAML
1740/// string with `---` document-start markers between each.
1741///
1742/// # Errors
1743///
1744/// All variants documented on [`to_string`]; the first failing
1745/// document short-circuits and returns its error — earlier
1746/// documents are not emitted.
1747///
1748/// # Examples
1749///
1750/// ```rust
1751/// let docs = vec![1, 2, 3];
1752/// let yaml = noyalib::to_string_multi(&docs).unwrap();
1753/// assert!(yaml.contains("---"));
1754/// ```
1755pub fn to_string_multi<T: serde_core::Serialize>(values: &[T]) -> Result<String> {
1756    to_string_multi_with_config(values, &SerializerConfig::default())
1757}
1758
1759/// Serialize an iterable of values as a multi-document YAML
1760/// string with a custom [`SerializerConfig`].
1761///
1762/// # Errors
1763///
1764/// All variants documented on [`to_string_with_config`].
1765pub fn to_string_multi_with_config<T: serde_core::Serialize>(
1766    values: &[T],
1767    config: &SerializerConfig,
1768) -> Result<String> {
1769    let mut output = String::new();
1770    for (i, value) in values.iter().enumerate() {
1771        if i > 0 {
1772            output.push('\n');
1773        }
1774        output.push_str("---\n");
1775        let v = to_value(value)?;
1776        write_value(&mut output, &v, 0, true, config, 0)?;
1777        output.push('\n');
1778    }
1779    Ok(output)
1780}
1781
1782/// Serialize multiple values as multi-document YAML to a writer.
1783///
1784/// # Errors
1785///
1786/// Returns an error if any value cannot be serialized or writing fails.
1787#[cfg(feature = "std")]
1788pub fn to_writer_multi<W, T>(writer: W, values: &[T]) -> Result<()>
1789where
1790    W: std::io::Write,
1791    T: serde_core::Serialize,
1792{
1793    to_writer_multi_with_config(writer, values, &SerializerConfig::default())
1794}
1795
1796/// Serialize multiple values as multi-document YAML to a writer with custom
1797/// configuration.
1798///
1799/// # Errors
1800///
1801/// Returns an error if any value cannot be serialized or writing fails.
1802#[cfg(feature = "std")]
1803pub fn to_writer_multi_with_config<W, T>(
1804    writer: W,
1805    values: &[T],
1806    config: &SerializerConfig,
1807) -> Result<()>
1808where
1809    W: std::io::Write,
1810    T: serde_core::Serialize,
1811{
1812    let s = to_string_multi_with_config(values, config)?;
1813    let mut writer = writer;
1814    writer.write_all(s.as_bytes())?;
1815    Ok(())
1816}
1817
1818/// A YAML serializer.
1819#[derive(Debug, Copy, Clone)]
1820pub struct Serializer;
1821
1822impl serde_core::ser::Serializer for Serializer {
1823    type Ok = Value;
1824    type Error = Error;
1825
1826    type SerializeSeq = SerializeSeq;
1827    type SerializeTuple = SerializeSeq;
1828    type SerializeTupleStruct = SerializeSeq;
1829    type SerializeTupleVariant = SerializeTupleVariant;
1830    type SerializeMap = SerializeMap;
1831    type SerializeStruct = SerializeMap;
1832    type SerializeStructVariant = SerializeStructVariant;
1833
1834    fn serialize_bool(self, v: bool) -> Result<Value> {
1835        Ok(Value::Bool(v))
1836    }
1837
1838    fn serialize_i8(self, v: i8) -> Result<Value> {
1839        self.serialize_i64(i64::from(v))
1840    }
1841
1842    fn serialize_i16(self, v: i16) -> Result<Value> {
1843        self.serialize_i64(i64::from(v))
1844    }
1845
1846    fn serialize_i32(self, v: i32) -> Result<Value> {
1847        self.serialize_i64(i64::from(v))
1848    }
1849
1850    fn serialize_i64(self, v: i64) -> Result<Value> {
1851        Ok(Value::Number(Number::Integer(v)))
1852    }
1853
1854    fn serialize_u8(self, v: u8) -> Result<Value> {
1855        self.serialize_i64(i64::from(v))
1856    }
1857
1858    fn serialize_u16(self, v: u16) -> Result<Value> {
1859        self.serialize_i64(i64::from(v))
1860    }
1861
1862    fn serialize_u32(self, v: u32) -> Result<Value> {
1863        self.serialize_i64(i64::from(v))
1864    }
1865
1866    fn serialize_u64(self, v: u64) -> Result<Value> {
1867        if let Ok(v) = i64::try_from(v) {
1868            return Ok(Value::Number(Number::Integer(v)));
1869        }
1870        // Values above `i64::MAX` require the `lossless-u64` feature.
1871        // Without it the `Number::Unsigned` variant does not exist and
1872        // there is no lossless representation to fall back to — return
1873        // an explicit serialise-time error so callers can surface the
1874        // limit to their users.
1875        #[cfg(feature = "lossless-u64")]
1876        {
1877            Ok(Value::Number(Number::Unsigned(v)))
1878        }
1879        #[cfg(not(feature = "lossless-u64"))]
1880        {
1881            Err(Error::Serialize(format!(
1882                "u64 value {v} exceeds i64::MAX and cannot be represented losslessly; \
1883                 enable the `lossless-u64` Cargo feature to opt in to unsigned integer support"
1884            )))
1885        }
1886    }
1887
1888    fn serialize_f32(self, v: f32) -> Result<Value> {
1889        self.serialize_f64(f64::from(v))
1890    }
1891
1892    fn serialize_f64(self, v: f64) -> Result<Value> {
1893        Ok(Value::Number(Number::Float(v)))
1894    }
1895
1896    fn serialize_char(self, v: char) -> Result<Value> {
1897        self.serialize_str(&v.to_string())
1898    }
1899
1900    fn serialize_str(self, v: &str) -> Result<Value> {
1901        Ok(Value::String(v.to_owned()))
1902    }
1903
1904    fn serialize_bytes(self, v: &[u8]) -> Result<Value> {
1905        // YAML 1.2.2 §10.4: byte buffers serialise as a `!!binary`
1906        // tagged scalar carrying the RFC 4648 base64 encoding of
1907        // the payload. This is the round-trip partner of the
1908        // `deserialize_bytes` path that recognises `!!binary` and
1909        // base64-decodes on demand. Holds for any `serde_bytes`
1910        // wrapper (`ByteBuf`, `Bytes`) and any `&[u8]` /
1911        // `Vec<u8>`-shaped target the caller annotates with
1912        // `#[serde(with = "serde_bytes")]`.
1913        let encoded = crate::base64::encode(v);
1914        Ok(Value::Tagged(Box::new(TaggedValue::new(
1915            Tag::new("!!binary"),
1916            Value::String(encoded),
1917        ))))
1918    }
1919
1920    fn serialize_none(self) -> Result<Value> {
1921        Ok(Value::Null)
1922    }
1923
1924    fn serialize_some<T>(self, value: &T) -> Result<Value>
1925    where
1926        T: ?Sized + serde_core::Serialize,
1927    {
1928        value.serialize(self)
1929    }
1930
1931    fn serialize_unit(self) -> Result<Value> {
1932        Ok(Value::Null)
1933    }
1934
1935    fn serialize_unit_struct(self, _name: &'static str) -> Result<Value> {
1936        self.serialize_unit()
1937    }
1938
1939    fn serialize_unit_variant(
1940        self,
1941        _name: &'static str,
1942        _variant_index: u32,
1943        variant: &'static str,
1944    ) -> Result<Value> {
1945        self.serialize_str(variant)
1946    }
1947
1948    fn serialize_newtype_struct<T>(self, name: &'static str, value: &T) -> Result<Value>
1949    where
1950        T: ?Sized + serde_core::Serialize,
1951    {
1952        // Intercept formatting hint magic names
1953        match name {
1954            crate::fmt::MAGIC_FLOW_SEQ
1955            | crate::fmt::MAGIC_FLOW_MAP
1956            | crate::fmt::MAGIC_LIT_STR
1957            | crate::fmt::MAGIC_FOLD_STR
1958            | crate::fmt::MAGIC_SPACE_AFTER => {
1959                let inner = value.serialize(Self)?;
1960                Ok(Value::Tagged(Box::new(TaggedValue::new(
1961                    Tag::new(name),
1962                    inner,
1963                ))))
1964            }
1965            crate::fmt::MAGIC_COMMENTED => {
1966                // value is a tuple (inner_value, comment_string)
1967                let inner = value.serialize(Self)?;
1968                Ok(Value::Tagged(Box::new(TaggedValue::new(
1969                    Tag::new(name),
1970                    inner,
1971                ))))
1972            }
1973            crate::fmt::MAGIC_ANCHOR_DEF | crate::fmt::MAGIC_ANCHOR_REF => {
1974                // ANCHOR_DEF: value serializes as Sequence([String(id), inner]).
1975                // ANCHOR_REF: value serializes as String(id).
1976                let inner = value.serialize(Self)?;
1977                Ok(Value::Tagged(Box::new(TaggedValue::new(
1978                    Tag::new(name),
1979                    inner,
1980                ))))
1981            }
1982            crate::fmt::MAGIC_TAGGED => {
1983                // `TaggedValue::serialize`'s wire form: a single-entry map
1984                // keyed by the tag string (#350). The marker name, not the
1985                // map's shape, is what rebuilds `Value::Tagged` here, so a
1986                // genuine one-entry mapping keyed by a `!`-string reaches
1987                // `SerializeMap::end` below and stays a mapping (#377).
1988                match value.serialize(Self)? {
1989                    Value::Mapping(map) if map.len() == 1 => {
1990                        let (tag, inner) = map
1991                            .into_iter()
1992                            .next()
1993                            .expect("length checked to be exactly one entry");
1994                        Ok(Value::Tagged(Box::new(TaggedValue::new(
1995                            Tag::new(tag),
1996                            inner,
1997                        ))))
1998                    }
1999                    other => Ok(other),
2000                }
2001            }
2002            _ => value.serialize(self),
2003        }
2004    }
2005
2006    fn serialize_newtype_variant<T>(
2007        self,
2008        _name: &'static str,
2009        _variant_index: u32,
2010        variant: &'static str,
2011        value: &T,
2012    ) -> Result<Value>
2013    where
2014        T: ?Sized + serde_core::Serialize,
2015    {
2016        let mut map = Mapping::new();
2017        let _ = map.insert(variant.to_owned(), value.serialize(Self)?);
2018        Ok(Value::Mapping(map))
2019    }
2020
2021    fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq> {
2022        Ok(SerializeSeq {
2023            vec: Vec::with_capacity(len.unwrap_or(0)),
2024        })
2025    }
2026
2027    fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple> {
2028        self.serialize_seq(Some(len))
2029    }
2030
2031    fn serialize_tuple_struct(
2032        self,
2033        _name: &'static str,
2034        len: usize,
2035    ) -> Result<Self::SerializeTupleStruct> {
2036        self.serialize_seq(Some(len))
2037    }
2038
2039    fn serialize_tuple_variant(
2040        self,
2041        _name: &'static str,
2042        _variant_index: u32,
2043        variant: &'static str,
2044        len: usize,
2045    ) -> Result<Self::SerializeTupleVariant> {
2046        Ok(SerializeTupleVariant {
2047            name: variant.to_owned(),
2048            vec: Vec::with_capacity(len),
2049        })
2050    }
2051
2052    fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap> {
2053        Ok(SerializeMap {
2054            map: Mapping::new(),
2055            key: None,
2056        })
2057    }
2058
2059    fn serialize_struct(self, _name: &'static str, len: usize) -> Result<Self::SerializeStruct> {
2060        self.serialize_map(Some(len))
2061    }
2062
2063    fn serialize_struct_variant(
2064        self,
2065        _name: &'static str,
2066        _variant_index: u32,
2067        variant: &'static str,
2068        _len: usize,
2069    ) -> Result<Self::SerializeStructVariant> {
2070        Ok(SerializeStructVariant {
2071            name: variant.to_owned(),
2072            map: Mapping::new(),
2073        })
2074    }
2075}
2076
2077/// Serializer for sequences.
2078#[derive(Debug)]
2079pub struct SerializeSeq {
2080    vec: Vec<Value>,
2081}
2082
2083impl serde_core::ser::SerializeSeq for SerializeSeq {
2084    type Ok = Value;
2085    type Error = Error;
2086
2087    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2088    where
2089        T: ?Sized + serde_core::Serialize,
2090    {
2091        self.vec.push(value.serialize(Serializer)?);
2092        Ok(())
2093    }
2094
2095    fn end(self) -> Result<Value> {
2096        Ok(Value::Sequence(self.vec))
2097    }
2098}
2099
2100impl serde_core::ser::SerializeTuple for SerializeSeq {
2101    type Ok = Value;
2102    type Error = Error;
2103
2104    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2105    where
2106        T: ?Sized + serde_core::Serialize,
2107    {
2108        serde_core::ser::SerializeSeq::serialize_element(self, value)
2109    }
2110
2111    fn end(self) -> Result<Value> {
2112        serde_core::ser::SerializeSeq::end(self)
2113    }
2114}
2115
2116impl serde_core::ser::SerializeTupleStruct for SerializeSeq {
2117    type Ok = Value;
2118    type Error = Error;
2119
2120    fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2121    where
2122        T: ?Sized + serde_core::Serialize,
2123    {
2124        serde_core::ser::SerializeSeq::serialize_element(self, value)
2125    }
2126
2127    fn end(self) -> Result<Value> {
2128        serde_core::ser::SerializeSeq::end(self)
2129    }
2130}
2131
2132/// Serializer for tuple variants.
2133#[derive(Debug)]
2134pub struct SerializeTupleVariant {
2135    name: String,
2136    vec: Vec<Value>,
2137}
2138
2139impl serde_core::ser::SerializeTupleVariant for SerializeTupleVariant {
2140    type Ok = Value;
2141    type Error = Error;
2142
2143    fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2144    where
2145        T: ?Sized + serde_core::Serialize,
2146    {
2147        self.vec.push(value.serialize(Serializer)?);
2148        Ok(())
2149    }
2150
2151    fn end(self) -> Result<Value> {
2152        let mut map = Mapping::new();
2153        let _ = map.insert(self.name, Value::Sequence(self.vec));
2154        Ok(Value::Mapping(map))
2155    }
2156}
2157
2158/// Serializer for maps.
2159#[derive(Debug)]
2160pub struct SerializeMap {
2161    map: Mapping,
2162    key: Option<String>,
2163}
2164
2165impl serde_core::ser::SerializeMap for SerializeMap {
2166    type Ok = Value;
2167    type Error = Error;
2168
2169    fn serialize_key<T>(&mut self, key: &T) -> Result<()>
2170    where
2171        T: ?Sized + serde_core::Serialize,
2172    {
2173        let key_value = key.serialize(Serializer)?;
2174        let key_str = match key_value {
2175            Value::String(s) => s,
2176            Value::Number(Number::Integer(n)) => n.to_string(),
2177            #[cfg(feature = "lossless-u64")]
2178            Value::Number(Number::Unsigned(n)) => n.to_string(),
2179            Value::Bool(b) => b.to_string(),
2180            _ => return Err(Error::Serialize("map key must be a string".to_string())),
2181        };
2182        self.key = Some(key_str);
2183        Ok(())
2184    }
2185
2186    fn serialize_value<T>(&mut self, value: &T) -> Result<()>
2187    where
2188        T: ?Sized + serde_core::Serialize,
2189    {
2190        let key = self
2191            .key
2192            .take()
2193            .ok_or_else(|| Error::Serialize("missing key".to_string()))?;
2194        let _ = self.map.insert(key, value.serialize(Serializer)?);
2195        Ok(())
2196    }
2197
2198    fn end(self) -> Result<Value> {
2199        Ok(Value::Mapping(self.map))
2200    }
2201}
2202
2203impl serde_core::ser::SerializeStruct for SerializeMap {
2204    type Ok = Value;
2205    type Error = Error;
2206
2207    fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2208    where
2209        T: ?Sized + serde_core::Serialize,
2210    {
2211        let _ = self
2212            .map
2213            .insert(key.to_owned(), value.serialize(Serializer)?);
2214        Ok(())
2215    }
2216
2217    fn end(self) -> Result<Value> {
2218        Ok(Value::Mapping(self.map))
2219    }
2220}
2221
2222/// Serializer for struct variants.
2223#[derive(Debug)]
2224pub struct SerializeStructVariant {
2225    name: String,
2226    map: Mapping,
2227}
2228
2229impl serde_core::ser::SerializeStructVariant for SerializeStructVariant {
2230    type Ok = Value;
2231    type Error = Error;
2232
2233    fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2234    where
2235        T: ?Sized + serde_core::Serialize,
2236    {
2237        let _ = self
2238            .map
2239            .insert(key.to_owned(), value.serialize(Serializer)?);
2240        Ok(())
2241    }
2242
2243    fn end(self) -> Result<Value> {
2244        let mut map = Mapping::new();
2245        let _ = map.insert(self.name, Value::Mapping(self.map));
2246        Ok(Value::Mapping(map))
2247    }
2248}
2249
2250#[cfg(test)]
2251mod tests {
2252    use super::*;
2253    use crate::Value;
2254
2255    #[test]
2256    fn test_serialization_recursion_limit() {
2257        let mut root = Value::Sequence(vec![Value::Null]);
2258        for _ in 0..200 {
2259            root = Value::Sequence(vec![root]);
2260        }
2261
2262        let config = SerializerConfig::default().max_depth(128);
2263        let result = to_string_with_config(&root, &config);
2264
2265        match result {
2266            Err(Error::RecursionLimitExceeded { depth }) => assert!(depth > 128),
2267            _ => panic!("Expected RecursionLimitExceeded error, got {result:?}"),
2268        }
2269    }
2270
2271    // Regression for #84: `flow_style` was stored but never consulted by the
2272    // emit path, so `FlowStyle::Flow` / `Auto` silently produced block output.
2273    #[test]
2274    fn test_flow_style_flow_emits_inline_collections() {
2275        let seq = Value::Sequence(vec![
2276            Value::from(0),
2277            Value::from(1),
2278            Value::from(2),
2279            Value::from(3),
2280            Value::from(4),
2281        ]);
2282        let mut map = Mapping::new();
2283        let _ = map.insert("a", Value::from(1));
2284        let _ = map.insert("b", Value::from(2));
2285        let _ = map.insert("c", Value::from(3));
2286        let map = Value::Mapping(map);
2287
2288        let config = SerializerConfig::new().flow_style(FlowStyle::Flow);
2289        assert_eq!(
2290            to_string_with_config(&seq, &config).unwrap().trim_end(),
2291            "[0, 1, 2, 3, 4]"
2292        );
2293        assert_eq!(
2294            to_string_with_config(&map, &config).unwrap().trim_end(),
2295            "{a: 1, b: 2, c: 3}"
2296        );
2297    }
2298
2299    #[test]
2300    fn test_flow_style_auto_respects_threshold() {
2301        let small = Value::Sequence((0..3).map(Value::from).collect());
2302        let large = Value::Sequence((0..10).map(Value::from).collect());
2303
2304        let config = SerializerConfig::new()
2305            .flow_style(FlowStyle::Auto)
2306            .flow_threshold(4);
2307
2308        // Small collection (<= threshold) flows inline.
2309        assert_eq!(
2310            to_string_with_config(&small, &config).unwrap().trim_end(),
2311            "[0, 1, 2]"
2312        );
2313        // Large collection (> threshold) stays block.
2314        assert!(
2315            to_string_with_config(&large, &config)
2316                .unwrap()
2317                .starts_with("- 0")
2318        );
2319    }
2320
2321    #[test]
2322    fn test_flow_style_auto_falls_back_when_child_exceeds_threshold() {
2323        // Outer has 2 items (<= threshold) but the inner sequence has 10
2324        // (> threshold). Flowing the outer would emit an invalid block child
2325        // inside flow, so Auto must keep the outer in block style.
2326        let inner = Value::Sequence((0..10).map(Value::from).collect());
2327        let outer = Value::Sequence(vec![Value::from(0), inner]);
2328
2329        let config = SerializerConfig::new()
2330            .flow_style(FlowStyle::Auto)
2331            .flow_threshold(4);
2332        let out = to_string_with_config(&outer, &config).unwrap();
2333        assert!(out.starts_with("- 0"), "outer should stay block: {out:?}");
2334    }
2335
2336    #[test]
2337    fn test_flow_style_block_is_default_unchanged() {
2338        let seq = Value::Sequence((0..3).map(Value::from).collect());
2339        // Default config (Block) and the no-config helper both stay block.
2340        assert!(to_string(&seq).unwrap().starts_with("- 0"));
2341        assert!(
2342            to_string_with_config(&seq, &SerializerConfig::new())
2343                .unwrap()
2344                .starts_with("- 0")
2345        );
2346    }
2347}