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/// Invariants:
17/// - Trimmed
18/// - ASCII uppercased
19/// - Whitespace collapsed to single underscores
20///
21/// Backed by [`SmolStr`] so canonical tokens that fit inline (≤ 23 bytes on
22/// 64-bit targets) avoid heap allocation entirely, and longer tokens use an
23/// `Arc<str>` so clones are O(1) refcount bumps. Most canonical tokens in
24/// this workspace — currency codes, exchange codes, period codes — are short
25/// enough to be stored inline.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub struct Canonical(SmolStr);
28
29impl Canonical {
30 /// Attempts to create a new canonical string from arbitrary input, rejecting
31 /// values that would canonicalize to an empty token (e.g., strings composed
32 /// solely of separators or non-alphanumeric characters).
33 ///
34 /// This should be used by all enum `Other` variants to ensure the emitted
35 /// string is always non-empty and round-trips via serde and `Display`.
36 ///
37 /// # Errors
38 ///
39 /// Returns `CanonicalError::InvalidCanonicalToken` when the canonicalized token would
40 /// be empty.
41 pub fn try_new(input: &str) -> Result<Self, CanonicalError> {
42 let token = canonicalize(input);
43 if token.is_empty() {
44 return Err(CanonicalError::InvalidCanonicalToken {
45 value: input.to_string(),
46 });
47 }
48 Ok(Self(SmolStr::new(token.as_ref())))
49 }
50
51 /// Returns the inner canonical string slice.
52 #[inline]
53 #[must_use]
54 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57
58 /// Consumes the `Canonical` and returns the inner value as a `String`.
59 #[inline]
60 #[must_use]
61 pub fn into_inner(self) -> String {
62 self.0.to_string()
63 }
64}
65
66impl fmt::Display for Canonical {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 f.write_str(self.as_ref())
69 }
70}
71
72impl AsRef<str> for Canonical {
73 fn as_ref(&self) -> &str {
74 self.0.as_ref()
75 }
76}
77
78impl Borrow<str> for Canonical {
79 fn borrow(&self) -> &str {
80 self.as_ref()
81 }
82}
83
84impl FromStr for Canonical {
85 type Err = CanonicalError;
86
87 fn from_str(s: &str) -> Result<Self, Self::Err> {
88 Self::try_new(s)
89 }
90}
91
92/// Produces the canonical representation of an input string used across enums.
93///
94/// All `Display`/serde string forms across enums are canonical tokens produced by this function.
95///
96/// # Canonical Form Contract
97///
98/// Canonical form is `[A-Z0-9]+(?:_[A-Z0-9]+)*`. Non-ASCII and non-alphanumeric characters
99/// are treated as separators. Empty after normalization → error.
100///
101/// # Canonicalization Rules
102///
103/// - **ASCII-only**: Only ASCII uppercase letters (A-Z) and digits (0-9) are preserved as-is
104/// - **Case normalization**: ASCII lowercase letters are converted to uppercase
105/// - **Separators**: All non-alphanumeric ASCII characters and Unicode codepoints become separators
106/// - **Separator handling**: Contiguous separators collapse to a single underscore `_`
107/// - **Trimming**: Leading and trailing separators are removed
108/// - **Underscores**: Multiple underscores collapse to single underscores; no leading/trailing/double underscores
109///
110/// Returns `Cow::Borrowed(input)` if `input` is already canonical; otherwise returns an owned, normalized string.
111#[inline]
112#[must_use]
113pub fn canonicalize(input: &str) -> Cow<'_, str> {
114 // Fast path: check if input is already canonical
115 if is_canonical(input) {
116 return Cow::Borrowed(input);
117 }
118
119 let mut out = String::with_capacity(input.len());
120 let mut prev_sep = true; // treat start as "just saw a separator" to skip leading seps
121
122 for ch in input.chars() {
123 let c = ch.to_ascii_uppercase();
124 if c.is_ascii_alphanumeric() {
125 out.push(c);
126 prev_sep = false;
127 } else if !prev_sep {
128 out.push('_');
129 prev_sep = true;
130 }
131 }
132
133 if out.ends_with('_') {
134 out.pop(); // drop trailing separator without reallocation
135 }
136
137 Cow::Owned(out)
138}
139
140/// Checks if a string is already in canonical form.
141///
142/// A string is canonical if:
143/// - All characters are ASCII uppercase letters or digits
144/// - There are no consecutive non-alphanumeric characters
145/// - There are no leading or trailing underscores
146#[inline]
147fn is_canonical(input: &str) -> bool {
148 let b = input.as_bytes();
149 if b.is_empty() || b[0] == b'_' || b[b.len() - 1] == b'_' {
150 return false;
151 }
152 let mut prev = b'_';
153 for &c in b {
154 match c {
155 b'A'..=b'Z' | b'0'..=b'9' => prev = c,
156 b'_' if prev != b'_' => prev = c,
157 _ => return false,
158 }
159 }
160 true
161}
162
163/// Trait for enums that have a canonical string code.
164///
165/// Implemented via macros across the paft workspace.
166pub trait StringCode {
167 /// Returns the canonical string code for this value.
168 fn code(&self) -> &str;
169
170 /// Whether this value is a canonical enum variant (not an `Other` payload).
171 fn is_canonical(&self) -> bool {
172 true
173 }
174}
175
176/// Errors that can occur when constructing canonical strings.
177#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
178#[non_exhaustive]
179pub enum CanonicalError {
180 /// Invalid canonical token produced by normalization helpers.
181 #[error("Invalid canonical token: '{value}' - canonicalized value must be non-empty")]
182 InvalidCanonicalToken {
183 /// The original input that failed to produce a canonical token.
184 value: String,
185 },
186}