Skip to main content

aws_smithy_schema/schema/document/
serializer.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! [`DocumentShapeSerializer`] — a [`ShapeSerializer`] implementation that
7//! builds up an [`aws_smithy_types::Document`] tree (wrapped in a
8//! [`DiscriminatedDocument`] at the root) from a typed Smithy shape.
9//!
10//! A [`ShapeSerializer`] receives a sequence of `write_*(schema, value)`
11//! calls driven either by `value.serialize_members(self)` (for structures)
12//! or by a closure passed to `write_list`/`write_map`. The serializer
13//! does not see the shape's static type; it only sees the schema and the
14//! value at each call site.
15//!
16//! [`DocumentShapeSerializer`] turns each such call into a [`Document`]
17//! node and stitches them into a tree using a small frame stack:
18//!
19//! - **No frame on the stack** — the next `write_*` call sets the root
20//!   document. The common entry point is
21//!   [`DiscriminatedDocument::from_struct`](crate::document::DiscriminatedDocumentExt::from_struct),
22//!   which immediately calls `write_struct` and produces a structure-
23//!   typed root. A bare `write_string` (etc.) at the top level produces
24//!   a scalar root, which is also valid (e.g. a payload that's just
25//!   `"foo"`).
26//! - **Struct frame** — each `write_*(member_schema, value)` insertion
27//!   uses `member_schema.member_name()` as the map key.
28//! - **List frame** — each `write_*` value is appended to the list.
29//! - **Map frame** — calls alternate `key`/`value`. The first call in a
30//!   map frame must produce a [`String`] document (the key); the next
31//!   call's value is bound to that key.
32//!
33//! When a frame's body completes, the serializer pops the frame, wraps
34//! it in the appropriate [`Document`] variant, and commits the wrapper
35//! to the parent frame (or to the root slot if the stack is now empty).
36
37use std::collections::HashMap;
38
39use aws_smithy_types::{BigDecimal, BigInteger, DateTime, DiscriminatedDocument, Document, Number};
40
41use crate::serde::{SerdeError, SerializableStruct, ShapeSerializer};
42use crate::Schema;
43
44/// Builds a [`DiscriminatedDocument`] from a typed shape via the
45/// [`ShapeSerializer`] interface.
46///
47/// See the module-level documentation for an overview.
48///
49/// # Discriminator capture
50///
51/// Top-level discriminator capture (e.g. via
52/// [`DiscriminatedDocument::from_struct`](crate::document::DiscriminatedDocumentExt::from_struct)
53/// or via direct calls like `ser.write_struct(&MY_SCHEMA, ...)`) goes
54/// through the inherent [`DocumentShapeSerializer::write_struct`]
55/// method, which captures the schema's shape ID into the serializer's
56/// `root_discriminator` slot.
57///
58/// Nested discriminator capture (a struct member that is itself a
59/// struct, called through `value.serialize_members(&mut dyn ShapeSerializer)`)
60/// is **not** preserved, because [`Document`] has no per-node
61/// discriminator slot — only the outer
62/// [`DiscriminatedDocument`] wrapper carries one. Discriminators apply
63/// at the wrapper level, not at every nested struct.
64///
65/// # Example
66///
67/// Use [`DiscriminatedDocument::from_struct`](crate::document::DiscriminatedDocumentExt::from_struct)
68/// for the common case of converting a [`SerializableStruct`] into a
69/// [`DiscriminatedDocument`]:
70///
71/// ```ignore
72/// use aws_smithy_schema::document::DiscriminatedDocumentExt;
73/// use aws_smithy_types::DiscriminatedDocument;
74///
75/// let doc = DiscriminatedDocument::from_struct(MyStruct::SCHEMA, &my_struct)?;
76/// ```
77///
78/// Direct construction is useful when round-tripping pieces of a tree
79/// outside the standard entry point:
80///
81/// ```ignore
82/// use aws_smithy_schema::document::DocumentShapeSerializer;
83/// use aws_smithy_schema::serde::ShapeSerializer;
84///
85/// let mut ser = DocumentShapeSerializer::new();
86/// ser.write_string(&aws_smithy_schema::prelude::STRING, "hello")?;
87/// let doc = ser.finish()?;
88/// ```
89#[derive(Debug, Default)]
90pub struct DocumentShapeSerializer {
91    /// Stack of in-progress containers. Top is the active write target.
92    stack: Vec<Frame>,
93    /// Holds the root document once it is committed (i.e. once a write_*
94    /// call returns with an empty stack).
95    finished: Option<Document>,
96    /// FQN of the top-level struct's schema, captured by the inherent
97    /// [`DocumentShapeSerializer::write_struct`] method. `None` for
98    /// non-struct top-level writes or when the top-level write went
99    /// through the trait method.
100    root_discriminator: Option<String>,
101}
102
103/// An in-progress container on the serializer's frame stack.
104#[derive(Debug)]
105enum Frame {
106    Struct {
107        members: HashMap<String, Document>,
108    },
109    List(Vec<Document>),
110    Map {
111        entries: HashMap<String, Document>,
112        /// `Some(k)` after a key has been written and we're awaiting the
113        /// matching value; `None` when we are at an entry boundary
114        /// (next write becomes the next key).
115        pending_key: Option<String>,
116    },
117}
118
119impl DocumentShapeSerializer {
120    /// Creates a fresh serializer with an empty frame stack.
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Consumes the serializer and returns the constructed
126    /// [`DiscriminatedDocument`].
127    ///
128    /// The wrapper carries the captured top-level discriminator (the
129    /// FQN of the schema passed to the inherent
130    /// [`DocumentShapeSerializer::write_struct`] entry point), if any.
131    /// Settings are not attached — serialize-side documents have no
132    /// protocol context.
133    ///
134    /// Returns an error if no value has been written yet, if any frame
135    /// is still open (caller forgot a closing callback), or if the
136    /// serializer was driven into a malformed state.
137    pub fn finish(self) -> Result<DiscriminatedDocument, SerdeError> {
138        if !self.stack.is_empty() {
139            return Err(SerdeError::custom(format!(
140                "DocumentShapeSerializer::finish called with {} unfinished container(s) on the stack",
141                self.stack.len()
142            )));
143        }
144        let document = self.finished.ok_or_else(|| {
145            SerdeError::custom(
146                "DocumentShapeSerializer::finish called before any value was written",
147            )
148        })?;
149        let mut wrapper = DiscriminatedDocument::new(document);
150        if let Some(d) = self.root_discriminator {
151            wrapper = wrapper.with_discriminator(d);
152        }
153        Ok(wrapper)
154    }
155
156    /// Inherent struct write that captures the schema's shape ID as the
157    /// serializer's top-level discriminator.
158    ///
159    /// Method resolution prefers this inherent method over the
160    /// [`ShapeSerializer::write_struct`] trait method when the receiver
161    /// is a concrete `&mut DocumentShapeSerializer` — so call sites
162    /// like
163    ///
164    /// ```ignore
165    /// let mut ser = DocumentShapeSerializer::new();
166    /// ser.write_struct(&MY_SCHEMA, &my_value)?;
167    /// ```
168    ///
169    /// hit this method. Indirect calls through `&mut dyn ShapeSerializer`
170    /// (e.g., from inside another struct's `serialize_members` callback)
171    /// take the trait method instead, which doesn't capture a
172    /// discriminator — nested struct discriminators are deliberately
173    /// not preserved (see the type-level docs).
174    pub fn write_struct(
175        &mut self,
176        schema: &Schema<'_>,
177        value: &dyn SerializableStruct,
178    ) -> Result<(), SerdeError> {
179        let is_top_level = self.stack.is_empty() && self.finished.is_none();
180        if is_top_level {
181            self.root_discriminator = Some(schema.shape_id().as_str().to_string());
182        }
183        // Capture the top-level discriminator above, then defer the
184        // actual struct framing to the trait method so both entry
185        // points share one implementation. Nested struct writes reach
186        // the trait method directly through `&mut dyn ShapeSerializer`
187        // and therefore never capture a discriminator.
188        <Self as ShapeSerializer>::write_struct(self, schema, value)
189    }
190
191    /// Routes a constructed [`Document`] into the active frame, or
192    /// commits it as the root if the stack is empty.
193    fn commit_value(&mut self, schema: &Schema<'_>, value: Document) -> Result<(), SerdeError> {
194        match self.stack.last_mut() {
195            None => {
196                if self.finished.is_some() {
197                    return Err(SerdeError::custom(
198                        "DocumentShapeSerializer received multiple top-level writes",
199                    ));
200                }
201                self.finished = Some(value);
202                Ok(())
203            }
204            Some(Frame::Struct { members }) => {
205                let name = schema.member_name().ok_or_else(|| {
206                    SerdeError::custom(format!(
207                        "writing a struct member requires a member schema with member_name set; got schema for {}",
208                        schema.shape_id().as_str()
209                    ))
210                })?;
211                members.insert(name.to_string(), value);
212                Ok(())
213            }
214            Some(Frame::List(items)) => {
215                items.push(value);
216                Ok(())
217            }
218            Some(Frame::Map {
219                entries,
220                pending_key,
221            }) => {
222                if let Some(key) = pending_key.take() {
223                    entries.insert(key, value);
224                } else {
225                    // First write in an entry must be the key, and
226                    // map keys are always strings in the Smithy data
227                    // model.
228                    let key = match value {
229                        Document::String(s) => s,
230                        other => {
231                            return Err(SerdeError::custom(format!(
232                                "map key must be a String document; got {}",
233                                shape_kind_name(&other),
234                            )))
235                        }
236                    };
237                    *pending_key = Some(key);
238                }
239                Ok(())
240            }
241        }
242    }
243}
244
245/// Human-readable name for a [`Document`] variant, used in error
246/// messages for type-mismatch diagnostics.
247fn shape_kind_name(d: &Document) -> &'static str {
248    match d {
249        Document::Null => "null",
250        Document::Bool(_) => "boolean",
251        Document::Number(_) => "number",
252        Document::String(_) => "string",
253        Document::Array(_) => "list",
254        Document::Object(_) => "map",
255    }
256}
257
258impl ShapeSerializer for DocumentShapeSerializer {
259    fn write_struct(
260        &mut self,
261        schema: &Schema<'_>,
262        value: &dyn SerializableStruct,
263    ) -> Result<(), SerdeError> {
264        // Trait dispatch path: nested struct writes from within
265        // `serialize_members` callbacks land here. We do NOT capture
266        // a discriminator — only the top-level inherent write_struct
267        // entry point does that (see the type-level docs).
268        self.stack.push(Frame::Struct {
269            members: HashMap::new(),
270        });
271        value.serialize_members(self)?;
272        let frame = self.stack.pop().expect("frame just pushed");
273        let members = match frame {
274            Frame::Struct { members } => members,
275            _ => {
276                return Err(SerdeError::custom(
277                    "DocumentShapeSerializer struct frame replaced by a different frame kind during serialization",
278                ));
279            }
280        };
281        self.commit_value(schema, Document::Object(members))
282    }
283
284    fn write_list(
285        &mut self,
286        schema: &Schema<'_>,
287        write_elements: &dyn Fn(&mut dyn ShapeSerializer) -> Result<(), SerdeError>,
288    ) -> Result<(), SerdeError> {
289        self.stack.push(Frame::List(Vec::new()));
290        write_elements(self)?;
291        let frame = self.stack.pop().expect("frame just pushed");
292        let items = match frame {
293            Frame::List(items) => items,
294            _ => {
295                return Err(SerdeError::custom(
296                    "DocumentShapeSerializer list frame replaced by a different frame kind during serialization",
297                ));
298            }
299        };
300        self.commit_value(schema, Document::Array(items))
301    }
302
303    fn write_map(
304        &mut self,
305        schema: &Schema<'_>,
306        write_entries: &dyn Fn(&mut dyn ShapeSerializer) -> Result<(), SerdeError>,
307    ) -> Result<(), SerdeError> {
308        self.stack.push(Frame::Map {
309            entries: HashMap::new(),
310            pending_key: None,
311        });
312        write_entries(self)?;
313        let frame = self.stack.pop().expect("frame just pushed");
314        let (entries, pending_key) = match frame {
315            Frame::Map {
316                entries,
317                pending_key,
318            } => (entries, pending_key),
319            _ => {
320                return Err(SerdeError::custom(
321                    "DocumentShapeSerializer map frame replaced by a different frame kind during serialization",
322                ));
323            }
324        };
325        if pending_key.is_some() {
326            return Err(SerdeError::custom(
327                "map ended with an unmatched key (key written without a matching value)",
328            ));
329        }
330        self.commit_value(schema, Document::Object(entries))
331    }
332
333    fn write_boolean(&mut self, schema: &Schema<'_>, value: bool) -> Result<(), SerdeError> {
334        self.commit_value(schema, Document::Bool(value))
335    }
336
337    fn write_byte(&mut self, schema: &Schema<'_>, value: i8) -> Result<(), SerdeError> {
338        // Smithy byte (i8) — encode through Number's signed-int slot.
339        self.commit_value(schema, Document::Number(signed_to_number(value as i64)))
340    }
341
342    fn write_short(&mut self, schema: &Schema<'_>, value: i16) -> Result<(), SerdeError> {
343        self.commit_value(schema, Document::Number(signed_to_number(value as i64)))
344    }
345
346    fn write_integer(&mut self, schema: &Schema<'_>, value: i32) -> Result<(), SerdeError> {
347        self.commit_value(schema, Document::Number(signed_to_number(value as i64)))
348    }
349
350    fn write_long(&mut self, schema: &Schema<'_>, value: i64) -> Result<(), SerdeError> {
351        self.commit_value(schema, Document::Number(signed_to_number(value)))
352    }
353
354    fn write_float(&mut self, schema: &Schema<'_>, value: f32) -> Result<(), SerdeError> {
355        self.commit_value(schema, Document::Number(Number::Float(value as f64)))
356    }
357
358    fn write_double(&mut self, schema: &Schema<'_>, value: f64) -> Result<(), SerdeError> {
359        self.commit_value(schema, Document::Number(Number::Float(value)))
360    }
361
362    fn write_big_integer(
363        &mut self,
364        schema: &Schema<'_>,
365        value: &BigInteger,
366    ) -> Result<(), SerdeError> {
367        // `Document` has no arbitrary-precision variant. The legacy
368        // representation is the value's numeric text, which
369        // `DocumentShapeDeserializer::read_big_integer` reverses on the
370        // way back in.
371        self.commit_value(schema, Document::String(value.as_ref().to_string()))
372    }
373
374    fn write_big_decimal(
375        &mut self,
376        schema: &Schema<'_>,
377        value: &BigDecimal,
378    ) -> Result<(), SerdeError> {
379        // See `write_big_integer`: numeric text, reversed by
380        // `DocumentShapeDeserializer::read_big_decimal`.
381        self.commit_value(schema, Document::String(value.as_ref().to_string()))
382    }
383
384    fn write_string(&mut self, schema: &Schema<'_>, value: &str) -> Result<(), SerdeError> {
385        self.commit_value(schema, Document::String(value.to_string()))
386    }
387
388    fn write_blob(
389        &mut self,
390        schema: &Schema<'_>,
391        value: aws_smithy_types::Blob,
392    ) -> Result<(), SerdeError> {
393        // `Document` has no blob variant. The legacy representation is a
394        // standard base64 string — the encoding every JSON-family
395        // protocol uses and the one `DiscriminatedDocument::as_blob`
396        // decodes by default.
397        self.commit_value(
398            schema,
399            Document::String(aws_smithy_types::base64::encode(value.as_ref())),
400        )
401    }
402
403    fn write_timestamp(&mut self, schema: &Schema<'_>, value: &DateTime) -> Result<(), SerdeError> {
404        // `Document` has no timestamp variant. The legacy representation
405        // is epoch seconds as a number, which
406        // `DiscriminatedDocument::as_timestamp` and
407        // `DocumentShapeDeserializer::read_timestamp` both reverse.
408        // Sub-second precision is retained by emitting a float; whole
409        // seconds stay exact integers.
410        self.commit_value(schema, Document::Number(timestamp_to_number(*value)))
411    }
412
413    fn write_document(&mut self, schema: &Schema<'_>, value: &Document) -> Result<(), SerdeError> {
414        // `Document` is fully owned, so this is just a clone.
415        self.commit_value(schema, value.clone())
416    }
417
418    fn write_null(&mut self, schema: &Schema<'_>) -> Result<(), SerdeError> {
419        self.commit_value(schema, Document::Null)
420    }
421}
422
423/// Routes a signed integer into [`Number::PosInt`] or [`Number::NegInt`]
424/// based on sign — [`Number::PosInt`] is the natural slot for non-
425/// negative values; [`Number::NegInt`] for negatives.
426fn signed_to_number(v: i64) -> Number {
427    if v >= 0 {
428        Number::PosInt(v as u64)
429    } else {
430        Number::NegInt(v)
431    }
432}
433
434/// Encodes a [`DateTime`] as epoch seconds in a [`Number`].
435///
436/// Whole-second timestamps become an exact integer so the common case
437/// round-trips bit-for-bit; timestamps carrying sub-second precision
438/// become a float, which retains the fraction at f64 precision. This
439/// mirrors what `epoch-seconds` means on the wire for the JSON-family
440/// protocols.
441fn timestamp_to_number(value: DateTime) -> Number {
442    if value.has_subsec_nanos() {
443        Number::Float(value.as_secs_f64())
444    } else {
445        signed_to_number(value.secs())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::serde::{SerdeError, SerializableStruct, ShapeSerializer};
453    use crate::{prelude, shape_id, Schema, ShapeId, ShapeType};
454
455    // -- Test schemas ----------------------------------------------------
456
457    const PERSON_ID: ShapeId<'static> = shape_id!("smithy.example", "Person");
458    const PERSON_NAME_ID: ShapeId<'static> = shape_id!("smithy.example", "Person", "name");
459    const PERSON_AGE_ID: ShapeId<'static> = shape_id!("smithy.example", "Person", "age");
460
461    static PERSON_NAME_MEMBER: Schema<'static> =
462        Schema::new_member(PERSON_NAME_ID, ShapeType::String, "name", 0);
463    static PERSON_AGE_MEMBER: Schema<'static> =
464        Schema::new_member(PERSON_AGE_ID, ShapeType::Integer, "age", 1);
465    static PERSON_SCHEMA: Schema<'static> = Schema::new_struct(
466        PERSON_ID,
467        ShapeType::Structure,
468        &[&PERSON_NAME_MEMBER, &PERSON_AGE_MEMBER],
469    );
470
471    /// `struct Person { name: String, age: i32 }`.
472    struct Person {
473        name: String,
474        age: i32,
475    }
476
477    impl SerializableStruct for Person {
478        fn schema(&self) -> &Schema<'_> {
479            &PERSON_SCHEMA
480        }
481
482        fn serialize_members(&self, ser: &mut dyn ShapeSerializer) -> Result<(), SerdeError> {
483            ser.write_string(&PERSON_NAME_MEMBER, &self.name)?;
484            ser.write_integer(&PERSON_AGE_MEMBER, self.age)?;
485            Ok(())
486        }
487    }
488
489    // -- Top-level scalar -----------------------------------------------
490
491    #[test]
492    fn top_level_scalar_string_becomes_root() {
493        let mut ser = DocumentShapeSerializer::new();
494        ser.write_string(&prelude::STRING, "hello").unwrap();
495        let doc = ser.finish().unwrap();
496        assert_eq!(doc.document().as_string(), Some("hello"));
497        assert!(doc.discriminator().is_none());
498    }
499
500    #[test]
501    fn top_level_scalar_integer_becomes_root() {
502        let mut ser = DocumentShapeSerializer::new();
503        ser.write_integer(&prelude::INTEGER, 42).unwrap();
504        let doc = ser.finish().unwrap();
505        // 42 is non-negative → encoded as Number::PosInt(42).
506        assert!(matches!(
507            doc.document().as_number(),
508            Some(Number::PosInt(42))
509        ));
510    }
511
512    #[test]
513    fn top_level_null_becomes_root() {
514        let mut ser = DocumentShapeSerializer::new();
515        ser.write_null(&prelude::STRING).unwrap();
516        let doc = ser.finish().unwrap();
517        assert!(matches!(doc.document(), Document::Null));
518    }
519
520    // -- Struct ----------------------------------------------------------
521
522    #[test]
523    fn write_struct_produces_object_with_discriminator() {
524        let mut ser = DocumentShapeSerializer::new();
525        ser.write_struct(
526            &PERSON_SCHEMA,
527            &Person {
528                name: "Iago".into(),
529                age: 7,
530            },
531        )
532        .unwrap();
533        let doc = ser.finish().unwrap();
534
535        assert_eq!(doc.discriminator(), Some("smithy.example#Person"));
536        let map = doc.document().as_object().unwrap();
537        assert!(matches!(map.get("name"), Some(Document::String(s)) if s == "Iago"));
538        assert!(matches!(map.get("age"), Some(Document::Number(_))));
539    }
540
541    // -- List ------------------------------------------------------------
542
543    #[test]
544    fn write_list_of_strings() {
545        let mut ser = DocumentShapeSerializer::new();
546        let values = ["a", "b", "c"];
547        ser.write_list(&prelude::DOCUMENT, &|inner| {
548            for v in &values {
549                inner.write_string(&prelude::STRING, v)?;
550            }
551            Ok(())
552        })
553        .unwrap();
554        let doc = ser.finish().unwrap();
555        let items = doc.document().as_array().unwrap();
556        assert_eq!(items.len(), 3);
557        assert!(matches!(&items[0], Document::String(s) if s == "a"));
558        assert!(matches!(&items[2], Document::String(s) if s == "c"));
559    }
560
561    #[test]
562    fn write_sparse_list_with_nulls() {
563        let mut ser = DocumentShapeSerializer::new();
564        ser.write_list(&prelude::DOCUMENT, &|inner| {
565            inner.write_string(&prelude::STRING, "first")?;
566            inner.write_null(&prelude::STRING)?;
567            inner.write_string(&prelude::STRING, "third")?;
568            Ok(())
569        })
570        .unwrap();
571        let doc = ser.finish().unwrap();
572        let items = doc.document().as_array().unwrap();
573        assert_eq!(items.len(), 3);
574        assert!(matches!(&items[0], Document::String(s) if s == "first"));
575        assert!(matches!(&items[1], Document::Null));
576        assert!(matches!(&items[2], Document::String(s) if s == "third"));
577    }
578
579    // -- Map -------------------------------------------------------------
580
581    #[test]
582    fn write_map_of_strings() {
583        let mut ser = DocumentShapeSerializer::new();
584        ser.write_map(&prelude::DOCUMENT, &|inner| {
585            inner.write_string(&prelude::STRING, "key1")?;
586            inner.write_string(&prelude::STRING, "value1")?;
587            inner.write_string(&prelude::STRING, "key2")?;
588            inner.write_string(&prelude::STRING, "value2")?;
589            Ok(())
590        })
591        .unwrap();
592        let doc = ser.finish().unwrap();
593        let map = doc.document().as_object().unwrap();
594        assert_eq!(map.len(), 2);
595        assert!(matches!(map.get("key1"), Some(Document::String(s)) if s == "value1"));
596        assert!(matches!(map.get("key2"), Some(Document::String(s)) if s == "value2"));
597    }
598
599    #[test]
600    fn write_map_with_non_string_key_errors() {
601        let mut ser = DocumentShapeSerializer::new();
602        let result = ser.write_map(&prelude::DOCUMENT, &|inner| {
603            inner.write_integer(&prelude::INTEGER, 1)?;
604            inner.write_string(&prelude::STRING, "value")?;
605            Ok(())
606        });
607        let err = result.expect_err("non-string map key should error");
608        let msg = format!("{err}");
609        assert!(
610            msg.contains("map key must be a String document"),
611            "unexpected error message: {msg}"
612        );
613    }
614
615    #[test]
616    fn write_map_with_unmatched_key_errors() {
617        let mut ser = DocumentShapeSerializer::new();
618        let result = ser.write_map(&prelude::DOCUMENT, &|inner| {
619            inner.write_string(&prelude::STRING, "lonely-key")?;
620            Ok(())
621        });
622        let err = result.expect_err("trailing key without value should error");
623        let msg = format!("{err}");
624        assert!(
625            msg.contains("unmatched key"),
626            "unexpected error message: {msg}"
627        );
628    }
629
630    // -- Nested aggregates ----------------------------------------------
631
632    #[test]
633    fn nested_struct_in_map_round_trips_data_but_loses_inner_discriminator() {
634        // map<String, Person> with a single entry. Nested Person
635        // discriminator is NOT preserved by design (see type-level
636        // docs): `Document` has no per-node discriminator slot —
637        // only the outer `DiscriminatedDocument` wrapper carries
638        // one. We still verify the data round-trips.
639        let mut ser = DocumentShapeSerializer::new();
640        ser.write_map(&prelude::DOCUMENT, &|inner| {
641            inner.write_string(&prelude::STRING, "owner")?;
642            inner.write_struct(
643                &PERSON_SCHEMA,
644                &Person {
645                    name: "Alex".into(),
646                    age: 30,
647                },
648            )?;
649            Ok(())
650        })
651        .unwrap();
652        let doc = ser.finish().unwrap();
653        let map = doc.document().as_object().unwrap();
654        let nested = map.get("owner").unwrap();
655        let nested_members = nested.as_object().unwrap();
656        assert!(matches!(
657            nested_members.get("name"),
658            Some(Document::String(s)) if s == "Alex"
659        ));
660    }
661
662    // -- write_document round-trips a Document --------------------------
663
664    #[test]
665    fn write_document_commits_value_directly() {
666        let nested = Document::Object(HashMap::from([(
667            "foo".to_string(),
668            Document::String("bar".to_string()),
669        )]));
670        let mut ser = DocumentShapeSerializer::new();
671        ser.write_document(&prelude::DOCUMENT, &nested).unwrap();
672        let doc = ser.finish().unwrap();
673        let map = doc.document().as_object().unwrap();
674        assert!(matches!(map.get("foo"), Some(Document::String(s)) if s == "bar"));
675    }
676
677    // -- Finish error paths ---------------------------------------------
678
679    #[test]
680    fn finish_with_no_writes_errors() {
681        let ser = DocumentShapeSerializer::new();
682        let err = ser.finish().expect_err("empty serializer should error");
683        let msg = format!("{err}");
684        assert!(
685            msg.contains("before any value was written"),
686            "unexpected error message: {msg}"
687        );
688    }
689
690    #[test]
691    fn double_top_level_write_errors() {
692        let mut ser = DocumentShapeSerializer::new();
693        ser.write_string(&prelude::STRING, "first").unwrap();
694        let err = ser
695            .write_string(&prelude::STRING, "second")
696            .expect_err("second top-level write should error");
697        let msg = format!("{err}");
698        assert!(
699            msg.contains("multiple top-level writes"),
700            "unexpected error message: {msg}"
701        );
702    }
703}