noyalib/tag_registry.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Streaming-path registry for custom YAML tag pass-through.
5//!
6//! The default streaming deserializer routes values carrying a non-core
7//! tag (anything outside `!!int`, `!!str`, `!!bool`, …) through the AST
8//! fallback so the tag survives as `Value::Tagged(...)`. That's correct
9//! when a caller is reading into `Value`, but it forces an unnecessary
10//! allocation when the caller already knows the tag maps to a concrete
11//! Rust type.
12//!
13//! [`TagRegistry`] lets a caller declare which custom tags should
14//! instead be *stripped* on the streaming path so the inner scalar,
15//! sequence, or mapping deserializes directly into the target type —
16//! no AST detour, no `#[serde(rename = "!…")]` dance.
17//!
18//! # Examples
19//!
20//! ```
21//! use noyalib::{from_str_with_config, ParserConfig, TagRegistry};
22//! use std::sync::Arc;
23//!
24//! #[derive(Debug, serde::Deserialize, PartialEq)]
25//! struct Celsius(f64);
26//!
27//! let registry = Arc::new(TagRegistry::new().with("!Celsius"));
28//! let cfg = ParserConfig::new().tag_registry(registry);
29//!
30//! // Without the registry, `!Celsius 42.5` becomes Value::Tagged and a
31//! // plain `f64` deserialize fails. With the registry, the tag is
32//! // stripped on the streaming path and the scalar deserializes
33//! // directly into the target type.
34//! let c: Celsius = from_str_with_config("!Celsius 42.5", &cfg).unwrap();
35//! assert_eq!(c, Celsius(42.5));
36//! ```
37//!
38//! # When you still need `#[serde(rename = "!tag")]`
39//!
40//! The registry strips tags; it does not dispatch. If you're reading a
41//! tagged value into an *enum* where the tag chooses the variant
42//! (`!bang` → `Msg::Bang`, `!quiet` → `Msg::Quiet`), keep using
43//! `#[serde(rename)]` — serde itself needs the tag string to pick the
44//! variant. The registry is for "I already know the type; just get out
45//! of my way" newtype/unit-style use cases (the robotics, scientific,
46//! and units scenarios in particular).
47
48use crate::prelude::FxHashSet;
49use crate::prelude::*;
50
51/// A set of custom YAML tags that the streaming deserializer should
52/// strip and pass through instead of routing via the AST fallback.
53///
54/// Clone-cheap (just a set of owned strings); typically wrapped in an
55/// `Arc` and shared across calls. See the [module-level
56/// documentation](crate::tag_registry) for semantics.
57///
58/// # Examples
59///
60/// ```
61/// use noyalib::TagRegistry;
62/// let mut reg = TagRegistry::new();
63/// let _ = reg.register("!Celsius");
64/// let _ = reg.register("!Meters");
65/// assert!(reg.contains("!Celsius"));
66/// assert_eq!(reg.len(), 2);
67/// ```
68#[derive(Debug, Clone, Default)]
69pub struct TagRegistry {
70 known: FxHashSet<String>,
71}
72
73impl TagRegistry {
74 /// Construct an empty registry.
75 ///
76 /// # Examples
77 ///
78 /// ```
79 /// let reg = noyalib::TagRegistry::new();
80 /// assert!(reg.is_empty());
81 /// ```
82 #[must_use]
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 /// Mark `tag` as known. Accepts both handle-prefixed forms
88 /// (`!Celsius`, `!!custom`) and verbatim tag text. Returns `&mut
89 /// self` so callers can chain registrations.
90 ///
91 /// # Examples
92 ///
93 /// ```
94 /// let mut reg = noyalib::TagRegistry::new();
95 /// let _ = reg.register("!Celsius").register("!Fahrenheit");
96 /// assert!(reg.contains("!Celsius"));
97 /// assert!(reg.contains("!Fahrenheit"));
98 /// ```
99 pub fn register(&mut self, tag: impl Into<String>) -> &mut Self {
100 let _ = self.known.insert(tag.into());
101 self
102 }
103
104 /// Chainable `register` that consumes `self` — convenient when
105 /// building a registry inline.
106 ///
107 /// # Examples
108 ///
109 /// ```
110 /// let reg = noyalib::TagRegistry::new()
111 /// .with("!Celsius")
112 /// .with("!Meters");
113 /// assert_eq!(reg.len(), 2);
114 /// ```
115 #[must_use]
116 pub fn with(mut self, tag: impl Into<String>) -> Self {
117 let _ = self.known.insert(tag.into());
118 self
119 }
120
121 /// Is `tag` registered as a strip-through tag?
122 ///
123 /// # Examples
124 ///
125 /// ```
126 /// let reg = noyalib::TagRegistry::new().with("!Celsius");
127 /// assert!(reg.contains("!Celsius"));
128 /// assert!(!reg.contains("!Fahrenheit"));
129 /// ```
130 #[must_use]
131 pub fn contains(&self, tag: &str) -> bool {
132 self.known.contains(tag)
133 }
134
135 /// Number of tags registered.
136 ///
137 /// # Examples
138 ///
139 /// ```
140 /// let reg = noyalib::TagRegistry::new().with("!A").with("!B");
141 /// assert_eq!(reg.len(), 2);
142 /// ```
143 #[must_use]
144 pub fn len(&self) -> usize {
145 self.known.len()
146 }
147
148 /// Returns `true` when no tags are registered.
149 ///
150 /// # Examples
151 ///
152 /// ```
153 /// assert!(noyalib::TagRegistry::new().is_empty());
154 /// ```
155 #[must_use]
156 pub fn is_empty(&self) -> bool {
157 self.known.is_empty()
158 }
159
160 /// Remove every registration.
161 ///
162 /// # Examples
163 ///
164 /// ```
165 /// let mut reg = noyalib::TagRegistry::new().with("!A");
166 /// reg.clear();
167 /// assert!(reg.is_empty());
168 /// ```
169 pub fn clear(&mut self) {
170 self.known.clear();
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn new_is_empty() {
180 assert!(
181 TagRegistry::new().is_empty(),
182 "expected empty, got {:?}",
183 TagRegistry::new()
184 );
185 }
186
187 #[test]
188 fn register_and_contains() {
189 let mut reg = TagRegistry::new();
190 let _ = reg.register("!Celsius");
191 assert!(reg.contains("!Celsius"));
192 assert!(!reg.contains("!Fahrenheit"));
193 }
194
195 #[test]
196 fn with_chain() {
197 let reg = TagRegistry::new().with("!a").with("!b").with("!c");
198 assert_eq!(reg.len(), 3);
199 }
200
201 #[test]
202 fn clear_empties() {
203 let mut reg = TagRegistry::new().with("!a");
204 reg.clear();
205 assert!(reg.is_empty(), "expected empty, got {reg:?}");
206 }
207
208 #[test]
209 fn duplicates_collapse() {
210 let mut reg = TagRegistry::new();
211 let _ = reg.register("!x");
212 let _ = reg.register("!x");
213 assert_eq!(reg.len(), 1);
214 }
215}