//! Casemapping-aware IRC string types. //! //! [`IrcStr`] (borrowed) and [`IrcString`] (owned) wrap UTF-8 text but compare, //! order, and hash under RFC 1459 case folding (see [`leveva_casemap`]). They are to //! `str`/`String` what [`std::path::Path`]/[`PathBuf`] are to `str`/`String`: thin //! wrappers that change comparison semantics, not storage. //! //! ``` //! use leveva_string::IrcString; //! let a = IrcString::try_from("Nick[]").unwrap(); //! let b = IrcString::try_from("nick{}").unwrap(); //! assert_eq!(a, b); // RFC 1459: [] are the uppercase of {} //! ``` //! //! This crate was extracted from `leveva` (the IRC daemon) as a self-contained leaf: //! it depends only on [`leveva_casemap`] and is re-exported there as `leveva::string`. //! //! [`PathBuf`]: std::path::PathBuf use core::borrow::Borrow; use core::cmp::Ordering; use core::fmt; use core::hash::{Hash, Hasher}; use core::ops::Deref; use core::str::FromStr; use leveva_casemap as casemap; /// Error constructing an [`IrcString`] from raw text. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IrcError { /// The text contained a byte that can never appear inside an IRC message field: /// NUL (`\0`), CR (`\r`), or LF (`\n`). These are protocol framing bytes. Forbidden(char), } impl fmt::Display for IrcError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { IrcError::Forbidden(c) => { write!(f, "forbidden control byte {:?} in IRC string", c) } } } } impl std::error::Error for IrcError {} /// Reject the three bytes that may never appear in any IRC message field. fn validate_field(s: &str) -> Result<(), IrcError> { for c in s.chars() { if matches!(c, '\0' | '\r' | '\n') { return Err(IrcError::Forbidden(c)); } } Ok(()) } /// A borrowed IRC string slice: `str` with RFC 1459 case-insensitive /// equality/ordering/hashing. /// /// Construct one with [`IrcStr::new`] or by dereferencing an [`IrcString`]. #[repr(transparent)] pub struct IrcStr(str); impl IrcStr { /// Wrap a `&str` as a `&IrcStr` (zero-copy). #[inline] pub fn new(s: &str) -> &IrcStr { // SAFETY: IrcStr is #[repr(transparent)] over str, so the two have // identical layout; this is the standard unsized-newtype cast (cf. // std's `Path::new`). unsafe { &*(s as *const str as *const IrcStr) } } /// The underlying text, with its original (un-folded) casing preserved. #[inline] pub fn as_str(&self) -> &str { &self.0 } /// The underlying bytes. #[inline] pub fn as_bytes(&self) -> &[u8] { self.0.as_bytes() } } impl Deref for IrcStr { type Target = str; #[inline] fn deref(&self) -> &str { &self.0 } } impl PartialEq for IrcStr { #[inline] fn eq(&self, other: &Self) -> bool { casemap::eq_ignore_case(self.as_bytes(), other.as_bytes()) } } impl Eq for IrcStr {} impl PartialOrd for IrcStr { #[inline] fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for IrcStr { #[inline] fn cmp(&self, other: &Self) -> Ordering { casemap::cmp(self.as_bytes(), other.as_bytes()) } } impl Hash for IrcStr { #[inline] fn hash(&self, state: &mut H) { casemap::hash(self.as_bytes(), state); } } impl fmt::Display for IrcStr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl fmt::Debug for IrcStr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Debug::fmt(&self.0, f) } } impl AsRef for IrcStr { #[inline] fn as_ref(&self) -> &str { &self.0 } } impl ToOwned for IrcStr { type Owned = IrcString; #[inline] fn to_owned(&self) -> IrcString { IrcString(self.0.to_owned()) } } /// An owned IRC string: `String` with RFC 1459 case-insensitive semantics. /// /// Construction is fallible — the text may not contain NUL/CR/LF (see [`IrcError`]). #[derive(Clone)] pub struct IrcString(String); impl IrcString { /// Borrow as an [`IrcStr`]. #[inline] pub fn as_irc_str(&self) -> &IrcStr { IrcStr::new(&self.0) } /// The underlying text, original casing preserved. #[inline] pub fn as_str(&self) -> &str { &self.0 } /// Consume into the inner `String`. #[inline] pub fn into_string(self) -> String { self.0 } } impl Deref for IrcString { type Target = IrcStr; #[inline] fn deref(&self) -> &IrcStr { self.as_irc_str() } } impl Borrow for IrcString { #[inline] fn borrow(&self) -> &IrcStr { self.as_irc_str() } } impl PartialEq for IrcString { #[inline] fn eq(&self, other: &Self) -> bool { self.as_irc_str() == other.as_irc_str() } } impl Eq for IrcString {} impl PartialOrd for IrcString { #[inline] fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for IrcString { #[inline] fn cmp(&self, other: &Self) -> Ordering { self.as_irc_str().cmp(other.as_irc_str()) } } impl Hash for IrcString { #[inline] fn hash(&self, state: &mut H) { self.as_irc_str().hash(state) } } impl fmt::Display for IrcString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl fmt::Debug for IrcString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Debug::fmt(&self.0, f) } } impl AsRef for IrcString { #[inline] fn as_ref(&self) -> &str { &self.0 } } impl TryFrom for IrcString { type Error = IrcError; fn try_from(s: String) -> Result { validate_field(&s)?; Ok(IrcString(s)) } } impl TryFrom<&str> for IrcString { type Error = IrcError; fn try_from(s: &str) -> Result { validate_field(s)?; Ok(IrcString(s.to_owned())) } } impl FromStr for IrcString { type Err = IrcError; fn from_str(s: &str) -> Result { IrcString::try_from(s) } } /// Incrementally assemble an [`IrcString`], validating on [`build`](Self::build). /// /// ``` /// use leveva_string::IrcStringBuilder; /// let s = IrcStringBuilder::new().push_str("ni").push('c').push_str("k").build().unwrap(); /// assert_eq!(s.as_str(), "nick"); /// ``` #[derive(Default, Clone)] pub struct IrcStringBuilder { buf: String, } impl IrcStringBuilder { /// A new, empty builder. #[inline] pub fn new() -> Self { IrcStringBuilder { buf: String::new() } } /// A new builder with reserved capacity. #[inline] pub fn with_capacity(cap: usize) -> Self { IrcStringBuilder { buf: String::with_capacity(cap), } } /// Append a string slice. #[inline] pub fn push_str(mut self, s: &str) -> Self { self.buf.push_str(s); self } /// Append a single character. #[inline] pub fn push(mut self, c: char) -> Self { self.buf.push(c); self } /// Current length in bytes. #[inline] pub fn len(&self) -> usize { self.buf.len() } /// Whether nothing has been pushed yet. #[inline] pub fn is_empty(&self) -> bool { self.buf.is_empty() } /// Current contents (un-validated). #[inline] pub fn as_str(&self) -> &str { &self.buf } /// Finalize, validating that no forbidden control byte (NUL/CR/LF) was pushed. #[inline] pub fn build(self) -> Result { IrcString::try_from(self.buf) } } #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; #[test] fn casefold_equality() { let a = IrcString::try_from("Nick[]\\~").unwrap(); let b = IrcString::try_from("nick{}|^").unwrap(); assert_eq!(a, b); assert_eq!(a.as_irc_str(), b.as_irc_str()); } #[test] fn display_preserves_original_case() { let a = IrcString::try_from("AbC{}").unwrap(); assert_eq!(a.to_string(), "AbC{}"); assert_eq!(a.as_str(), "AbC{}"); } #[test] fn hash_agrees_with_eq() { let mut set: HashSet = HashSet::new(); set.insert(IrcString::try_from("Foo[Bar]").unwrap()); // Same name under RFC 1459 folding → already present, set stays size 1. assert!(!set.insert(IrcString::try_from("foo{bar}").unwrap())); assert_eq!(set.len(), 1); // A genuinely different name does get inserted. assert!(set.insert(IrcString::try_from("other").unwrap())); assert_eq!(set.len(), 2); } #[test] fn borrow_lookup_by_irc_str() { use std::collections::HashMap; let mut m: HashMap = HashMap::new(); m.insert(IrcString::try_from("Channel[A]").unwrap(), 7); // Look up via a borrowed &IrcStr with different casing. assert_eq!(m.get(IrcStr::new("channel{a}")), Some(&7)); assert_eq!(m.get(IrcStr::new("nope")), None); } #[test] fn ordering_is_case_insensitive() { let mut v = [ IrcString::try_from("Zebra").unwrap(), IrcString::try_from("apple").unwrap(), IrcString::try_from("Mango").unwrap(), ]; v.sort(); let names: Vec<&str> = v.iter().map(|s| s.as_str()).collect(); assert_eq!(names, ["apple", "Mango", "Zebra"]); } #[test] fn rejects_framing_bytes() { assert_eq!(IrcString::try_from("a\0b"), Err(IrcError::Forbidden('\0'))); assert_eq!(IrcString::try_from("a\rb"), Err(IrcError::Forbidden('\r'))); assert_eq!(IrcString::try_from("a\nb"), Err(IrcError::Forbidden('\n'))); assert!(IrcString::try_from("normal text with spaces").is_ok()); } #[test] fn builder_happy_and_sad() { let ok = IrcStringBuilder::new() .push_str("hello") .push(' ') .push_str("world") .build() .unwrap(); assert_eq!(ok.as_str(), "hello world"); let bad = IrcStringBuilder::new().push_str("oops\n").build(); assert_eq!(bad, Err(IrcError::Forbidden('\n'))); } // ----- Property / fuzz tier (stable-toolchain proptest, matching leveva-casemap) ----- use std::collections::hash_map::DefaultHasher; /// One-shot `Hash` → `u64`, for the hash-agrees-with-eq invariant. fn hash_of(v: &T) -> u64 { let mut h = DefaultHasher::new(); v.hash(&mut h); h.finish() } /// A `String` made wire-legal by stripping the three framing bytes, so the /// casemap-lift invariants exercise `IrcString` construction over arbitrary text. fn sanitize(s: &str) -> String { s.chars().filter(|c| !matches!(c, '\0' | '\r' | '\n')).collect() } proptest::proptest! { /// The wrapper adds no equality of its own: two slices are `IrcStr`-equal /// exactly when `casemap::eq_ignore_case` holds. Fed arbitrary strings. #[test] fn eq_lifts_casemap(a in ".*", b in ".*") { let lifted = IrcStr::new(&a) == IrcStr::new(&b); proptest::prop_assert_eq!(lifted, casemap::eq_ignore_case(a.as_bytes(), b.as_bytes())); } /// `Ord` is consistent with `Eq` (`cmp == Equal` iff `==`) and antisymmetric. #[test] fn ord_is_consistent_and_antisymmetric(a in ".*", b in ".*") { let (sa, sb) = (IrcStr::new(&a), IrcStr::new(&b)); proptest::prop_assert_eq!(sa.cmp(sb) == Ordering::Equal, sa == sb); proptest::prop_assert_eq!(sa.cmp(sb), sb.cmp(sa).reverse()); } /// The `HashMap`-key contract the daemon's nick/channel tables rely on: /// equal `IrcString`s hash equally. #[test] fn hash_agrees_with_eq_prop(a in ".*", b in ".*") { let (sa, sb) = (sanitize(&a), sanitize(&b)); let (ka, kb) = (IrcString::try_from(sa).unwrap(), IrcString::try_from(sb).unwrap()); if ka == kb { proptest::prop_assert_eq!(hash_of(&ka), hash_of(&kb)); } } /// `Borrow` + matching `Hash`/`Eq`: a value inserted under one casing /// is found by a case-different borrowed key (`map.get(IrcStr::new(..))`). #[test] fn borrow_round_trip(s in ".*") { use std::collections::HashMap; let clean = sanitize(&s); let key = IrcString::try_from(clean.clone()).unwrap(); let mut m: HashMap = HashMap::new(); m.insert(key, 42); // The same text (any casing folds equal to itself) must be found. proptest::prop_assert_eq!(m.get(IrcStr::new(&clean)), Some(&42)); } /// Validation soundness: `try_from` succeeds iff the text has no NUL/CR/LF, /// `FromStr` agrees, and on success storage is byte-identical (casing kept). #[test] fn validation_is_sound(s in ".*") { let has_framing = s.chars().any(|c| matches!(c, '\0' | '\r' | '\n')); let parsed: Result = s.parse(); proptest::prop_assert_eq!(IrcString::try_from(s.as_str()).is_ok(), !has_framing); proptest::prop_assert_eq!(parsed.is_ok(), !has_framing); if let Ok(v) = IrcString::try_from(s.as_str()) { proptest::prop_assert_eq!(v.as_str(), s.as_str()); } } /// `IrcStringBuilder` is a deferred validate: concatenating pieces and /// building equals validating the joined string, and rejects iff any piece /// carried a framing byte. #[test] fn builder_equals_try_from(parts in proptest::collection::vec(".*", 0..6)) { let mut b = IrcStringBuilder::new(); for p in &parts { b = b.push_str(p); } let joined: String = parts.concat(); proptest::prop_assert_eq!(b.build(), IrcString::try_from(joined)); } } }