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}