paft_utils/string_canonical.rs
1//! Shared canonical string utilities for extensible enums.
2//!
3//! All extensible enum `Other` branches must construct their canonical token via
4//! [`Canonical::try_new`] to guarantee we never serialize an empty string and thus
5//! preserve serde/display round-trips.
6
7use smol_str::SmolStr;
8use std::{
9 borrow::{Borrow, Cow},
10 fmt,
11 str::FromStr,
12};
13
14/// Canonical string wrapper used for `Other` variants.
15///
16/// Canonical tokens are capped at [`MAX_CANONICAL_TOKEN_LEN`] bytes. The cap is
17/// intentionally generous for provider enum codes while preventing unbounded
18/// unknown-token storage from untrusted inputs.
19///
20/// Invariants:
21/// - Trimmed
22/// - ASCII uppercased
23/// - Separator runs collapsed to single underscores
24/// - Non-empty and no longer than [`MAX_CANONICAL_TOKEN_LEN`] bytes
25///
26/// Backed by [`SmolStr`] so canonical tokens that fit inline (≤ 23 bytes on
27/// 64-bit targets) avoid heap allocation entirely, and longer tokens use an
28/// `Arc<str>` so clones are O(1) refcount bumps. Most canonical tokens in
29/// this workspace — currency codes, exchange codes, period codes — are short
30/// enough to be stored inline.
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
32pub struct Canonical(SmolStr);
33
34/// Maximum byte length of a canonical `Other` enum token.
35///
36/// Canonical tokens only contain ASCII uppercase letters, digits, and
37/// underscores, so byte length and character count are equivalent.
38pub const MAX_CANONICAL_TOKEN_LEN: usize = 256;
39
40impl Canonical {
41 /// Attempts to create a new canonical string from arbitrary input, rejecting
42 /// values that would canonicalize to an empty token (e.g., strings composed
43 /// solely of separators or non-alphanumeric characters), or whose
44 /// canonical form exceeds [`MAX_CANONICAL_TOKEN_LEN`] bytes.
45 ///
46 /// This should be used by all enum `Other` variants to ensure the emitted
47 /// string is always non-empty and round-trips via serde and `Display`.
48 ///
49 /// # Errors
50 ///
51 /// Returns [`CanonicalError::InvalidCanonicalToken`] when the canonicalized
52 /// token would be empty, or [`CanonicalError::CanonicalTokenTooLong`] when
53 /// it would exceed [`MAX_CANONICAL_TOKEN_LEN`] bytes.
54 pub fn try_new(input: &str) -> Result<Self, CanonicalError> {
55 let token = canonicalize_bounded(input)?;
56 if token.is_empty() {
57 return Err(CanonicalError::InvalidCanonicalToken {
58 value: input.to_string(),
59 });
60 }
61 Ok(Self(SmolStr::new(token.as_ref())))
62 }
63
64 /// Returns the inner canonical string slice.
65 #[inline]
66 #[must_use]
67 pub fn as_str(&self) -> &str {
68 &self.0
69 }
70
71 /// Consumes the `Canonical` and returns the inner value as a `String`.
72 #[inline]
73 #[must_use]
74 pub fn into_inner(self) -> String {
75 self.0.to_string()
76 }
77}
78
79fn canonicalize_bounded(input: &str) -> Result<Cow<'_, str>, CanonicalError> {
80 if is_canonical(input) {
81 if input.len() > MAX_CANONICAL_TOKEN_LEN {
82 return Err(CanonicalError::CanonicalTokenTooLong {
83 max_len: MAX_CANONICAL_TOKEN_LEN,
84 });
85 }
86 return Ok(Cow::Borrowed(input));
87 }
88
89 let mut out = String::with_capacity(input.len().min(MAX_CANONICAL_TOKEN_LEN));
90 let mut pending_sep = false;
91
92 for ch in input.chars() {
93 let c = ch.to_ascii_uppercase();
94 if c.is_ascii_alphanumeric() {
95 let sep_len = usize::from(pending_sep);
96 if out.len() + sep_len + 1 > MAX_CANONICAL_TOKEN_LEN {
97 return Err(CanonicalError::CanonicalTokenTooLong {
98 max_len: MAX_CANONICAL_TOKEN_LEN,
99 });
100 }
101 if pending_sep {
102 out.push('_');
103 }
104 out.push(c);
105 pending_sep = false;
106 } else if !out.is_empty() {
107 pending_sep = true;
108 }
109 }
110
111 Ok(Cow::Owned(out))
112}
113
114impl fmt::Display for Canonical {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(self.as_ref())
117 }
118}
119
120impl AsRef<str> for Canonical {
121 fn as_ref(&self) -> &str {
122 self.0.as_ref()
123 }
124}
125
126impl Borrow<str> for Canonical {
127 fn borrow(&self) -> &str {
128 self.as_ref()
129 }
130}
131
132impl FromStr for Canonical {
133 type Err = CanonicalError;
134
135 fn from_str(s: &str) -> Result<Self, Self::Err> {
136 Self::try_new(s)
137 }
138}
139
140/// Produces the canonical representation of an input string used across enums.
141///
142/// All `Display`/serde string forms across enums are canonical tokens produced by this function.
143///
144/// # Canonical Form Contract
145///
146/// Canonical form is `[A-Z0-9]+(?:_[A-Z0-9]+)*`. Non-ASCII and non-alphanumeric characters
147/// are treated as separators. Empty after normalization → error.
148///
149/// # Canonicalization Rules
150///
151/// - **ASCII-only**: Only ASCII uppercase letters (A-Z) and digits (0-9) are preserved as-is
152/// - **Case normalization**: ASCII lowercase letters are converted to uppercase
153/// - **Separators**: All non-alphanumeric ASCII characters and Unicode codepoints become separators
154/// - **Separator handling**: Contiguous separators collapse to a single underscore `_`
155/// - **Trimming**: Leading and trailing separators are removed
156/// - **Underscores**: Multiple underscores collapse to single underscores; no leading/trailing/double underscores
157///
158/// Returns `Cow::Borrowed(input)` if `input` is already canonical; otherwise returns an owned, normalized string.
159#[inline]
160#[must_use]
161pub fn canonicalize(input: &str) -> Cow<'_, str> {
162 // Fast path: check if input is already canonical
163 if is_canonical(input) {
164 return Cow::Borrowed(input);
165 }
166
167 let mut out = String::with_capacity(input.len());
168 let mut prev_sep = true; // treat start as "just saw a separator" to skip leading seps
169
170 for ch in input.chars() {
171 let c = ch.to_ascii_uppercase();
172 if c.is_ascii_alphanumeric() {
173 out.push(c);
174 prev_sep = false;
175 } else if !prev_sep {
176 out.push('_');
177 prev_sep = true;
178 }
179 }
180
181 if out.ends_with('_') {
182 out.pop(); // drop trailing separator without reallocation
183 }
184
185 Cow::Owned(out)
186}
187
188/// Returns true when `input` can safely be matched against a modeled enum token.
189///
190/// String enum parsers use this as a boundary check before resolving a
191/// canonicalized token to a known variant or alias. It allows ordinary
192/// case/separator normalization inside the token while rejecting leading or
193/// trailing separators such as `"$USD"` or `"CLOSED!"`, which would otherwise
194/// canonicalize into modeled values and lose their original identity.
195#[inline]
196#[must_use]
197pub fn has_canonical_token_boundaries(input: &str) -> bool {
198 let trimmed = input.trim();
199 let mut chars = trimmed.chars();
200 let Some(first) = chars.next() else {
201 return false;
202 };
203 let last = chars.next_back().unwrap_or(first);
204
205 first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric()
206}
207
208/// Checks if a string is already in canonical form.
209///
210/// A string is canonical if:
211/// - All characters are ASCII uppercase letters or digits
212/// - There are no consecutive non-alphanumeric characters
213/// - There are no leading or trailing underscores
214#[inline]
215fn is_canonical(input: &str) -> bool {
216 let b = input.as_bytes();
217 if b.is_empty() || b[0] == b'_' || b[b.len() - 1] == b'_' {
218 return false;
219 }
220 let mut prev = b'_';
221 for &c in b {
222 match c {
223 b'A'..=b'Z' | b'0'..=b'9' => prev = c,
224 b'_' if prev != b'_' => prev = c,
225 _ => return false,
226 }
227 }
228 true
229}
230
231/// Trait for enums that have a canonical string code.
232///
233/// Implemented via macros across the paft workspace.
234pub trait StringCode {
235 /// Returns the canonical string code for this value.
236 fn code(&self) -> &str;
237
238 /// Whether this value is a canonical enum variant (not an `Other` payload).
239 fn is_canonical(&self) -> bool {
240 true
241 }
242}
243
244/// Errors that can occur when constructing canonical strings.
245#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
246#[non_exhaustive]
247pub enum CanonicalError {
248 /// Invalid canonical token produced by normalization helpers.
249 #[error("Invalid canonical token: '{value}' - canonicalized value must be non-empty")]
250 InvalidCanonicalToken {
251 /// The original input that failed to produce a canonical token.
252 value: String,
253 },
254 /// Canonical token exceeded the configured maximum length.
255 #[error("canonical token exceeds maximum length of {max_len} bytes")]
256 CanonicalTokenTooLong {
257 /// Maximum accepted canonical token length in bytes.
258 max_len: usize,
259 },
260}