Skip to main content

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}