Skip to main content

noyalib/
flattened.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! `Flattened<T>` — capture the underlying [`Value`] alongside the
5//! typed deserialization for `#[serde(flatten)]` targets.
6//!
7//! `#[serde(flatten)]` is the idiomatic way to collect "extra" fields
8//! into a residue type, but the built-in residue types (`HashMap<String,
9//! Value>`, `serde_json::Value`, our `Value`) erase the typed view: you
10//! get the residue keys *or* the typed struct, never both.
11//!
12//! `Flattened<T>` solves that by capturing the [`Value`] tree first,
13//! then re-running deserialization into `T` from the captured tree.
14//! Both views are exposed:
15//!
16//! - `flattened.value: T` — the typed struct view.
17//! - `flattened.raw: Value` — the dynamic view that lets callers walk
18//!   the original data, look up unknown keys, run schema validation,
19//!   or attach span info via [`crate::cst::Document::span_at`].
20
21use crate::value::Value;
22
23/// Wrapper that pairs a typed deserialization of `T` with the
24/// underlying [`Value`] tree captured from the source.
25///
26/// `Flattened<T>` is the answer to "I want `#[serde(flatten)]` plus
27/// the dynamic view for span lookup / unknown-field detection /
28/// schema validation". It deserializes by:
29///
30/// 1. First capturing the input as a [`Value`] (preserving every
31///    key the source supplied, including ones the typed `T` may
32///    not declare).
33/// 2. Then re-running `T::deserialize` against the captured
34///    [`Value`] via [`crate::from_value`].
35///
36/// The cost is one extra [`Value`] tree allocation per
37/// `Flattened<T>` field and one extra deserialize pass — small
38/// compared to the typical config-loading hot path.
39///
40/// # Examples
41///
42/// ```
43/// use noyalib::{from_str, Flattened, Value};
44///
45/// #[derive(Debug, serde::Deserialize)]
46/// struct Inner {
47///     port: u16,
48/// }
49///
50/// #[derive(Debug, serde::Deserialize)]
51/// struct Config {
52///     name: String,
53///     // Capture both the typed view and the raw mapping so the
54///     // application layer can still inspect unknown keys.
55///     inner: Flattened<Inner>,
56/// }
57///
58/// let cfg: Config = from_str(
59///     "name: noyalib\ninner:\n  port: 8080\n  extra: not-in-Inner\n",
60/// ).unwrap();
61///
62/// // Typed view — the fields Inner declares.
63/// assert_eq!(cfg.inner.value.port, 8080);
64///
65/// // Raw view — the full mapping including the `extra` key.
66/// match &cfg.inner.raw {
67///     Value::Mapping(m) => {
68///         assert!(m.contains_key("port"));
69///         assert!(m.contains_key("extra"));
70///     }
71///     other => panic!("expected mapping, got {other:?}"),
72/// }
73/// ```
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Flattened<T> {
76    /// The typed deserialization of the underlying value.
77    pub value: T,
78    /// The raw [`Value`] tree captured from the source, before
79    /// typed projection. Useful for span lookup, unknown-key
80    /// inspection, schema validation, and round-trip serialization.
81    pub raw: Value,
82}
83
84impl<T> Flattened<T> {
85    /// Construct a [`Flattened`] from an explicit (raw, typed) pair.
86    ///
87    /// This is mostly useful for tests and bespoke deserialization
88    /// flows. Most callers obtain `Flattened<T>` via `serde::Deserialize`.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use noyalib::{Flattened, Value};
94    /// let f = Flattened::new(42_u16, Value::from(42_i64));
95    /// assert_eq!(f.value, 42);
96    /// assert_eq!(f.raw.as_i64(), Some(42));
97    /// ```
98    #[must_use]
99    pub fn new(value: T, raw: Value) -> Self {
100        Self { value, raw }
101    }
102
103    /// Consume the wrapper and return only the typed view.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use noyalib::{Flattened, Value};
109    /// let f = Flattened::new("hello".to_string(), Value::from("hello"));
110    /// assert_eq!(f.into_value(), "hello");
111    /// ```
112    #[must_use]
113    pub fn into_value(self) -> T {
114        self.value
115    }
116
117    /// Borrow the typed view.
118    pub fn as_value(&self) -> &T {
119        &self.value
120    }
121
122    /// Borrow the raw [`Value`] view.
123    pub fn as_raw(&self) -> &Value {
124        &self.raw
125    }
126}
127
128impl<T> core::ops::Deref for Flattened<T> {
129    type Target = T;
130    fn deref(&self) -> &T {
131        &self.value
132    }
133}
134
135impl<'de, T> serde_core::Deserialize<'de> for Flattened<T>
136where
137    T: for<'a> serde_core::Deserialize<'a> + 'static,
138{
139    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
140    where
141        D: serde_core::Deserializer<'de>,
142    {
143        // Step 1: capture the source as a typed `Value` tree —
144        // every key the source supplied is preserved.
145        let raw = Value::deserialize(deserializer)?;
146        // Step 2: re-run `T::deserialize` against the captured
147        // value via `from_value`. The HRTB on `T` lets the
148        // returned `T` outlive the temporary `&raw` borrow.
149        let value = crate::from_value::<T>(&raw).map_err(serde_core::de::Error::custom)?;
150        Ok(Self { value, raw })
151    }
152}
153
154impl<T: serde_core::Serialize> serde_core::Serialize for Flattened<T> {
155    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
156    where
157        S: serde_core::Serializer,
158    {
159        // Round-trip transparency: serializing a `Flattened<T>`
160        // is equivalent to serializing the typed view alone. This
161        // matches the expectation that `Flattened<T>` is a
162        // *capture* wrapper, not a separate schema element.
163        self.value.serialize(serializer)
164    }
165}