use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
pub struct ShortString<const MAX: usize>(String);
impl<const MAX: usize> ShortString<MAX> {
pub fn new(s: impl Into<String>) -> Result<Self, ShortStringError> {
let s = s.into();
let len = s.chars().count();
if len > MAX {
return Err(ShortStringError { got: len, max: MAX });
}
Ok(ShortString(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub const MAX: usize = MAX;
pub fn clipped(s: &str) -> (Self, bool) {
if s.chars().count() <= MAX {
return (ShortString(s.to_string()), false);
}
let head: String = s.chars().take(MAX).collect();
let sentence = head
.char_indices()
.filter(|&(i, c)| {
matches!(c, '.' | '!' | '?')
&& head[i + c.len_utf8()..]
.chars()
.next()
.is_none_or(char::is_whitespace)
})
.map(|(i, c)| i + c.len_utf8())
.next_back()
.filter(|&end| head[..end].chars().count() >= MAX / 2);
let out = match sentence {
Some(end) => head[..end].to_string(),
None => {
let room: String = head.chars().take(MAX - 1).collect();
let cut = room.rfind(char::is_whitespace).unwrap_or(room.len());
format!("{}…", room[..cut].trim_end())
}
};
(ShortString(out), true)
}
}
#[derive(Debug, Clone)]
pub struct ShortStringError {
pub got: usize,
pub max: usize,
}
impl core::fmt::Display for ShortStringError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"exceeds {} chars (got {}) — shorten and try again",
self.max, self.got,
)
}
}
impl std::error::Error for ShortStringError {}
impl<const MAX: usize> core::ops::Deref for ShortString<MAX> {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl<const MAX: usize> AsRef<str> for ShortString<MAX> {
fn as_ref(&self) -> &str {
&self.0
}
}
impl<const MAX: usize> core::fmt::Display for ShortString<MAX> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
impl<const MAX: usize> Serialize for ShortString<MAX> {
fn serialize<S: Serializer>(
&self,
serializer: S,
) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de, const MAX: usize> Deserialize<'de> for ShortString<MAX> {
fn deserialize<D: Deserializer<'de>>(
deserializer: D,
) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
ShortString::<MAX>::new(s).map_err(serde::de::Error::custom)
}
}
impl<const MAX: usize> schemars::JsonSchema for ShortString<MAX> {
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Owned(format!("ShortString_{MAX}"))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"maxLength": MAX,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_under_max() {
let s: ShortString<10> = serde_json::from_str("\"hello\"").unwrap();
assert_eq!(s.as_str(), "hello");
}
#[test]
fn deserialize_at_max() {
let s: ShortString<5> = serde_json::from_str("\"hello\"").unwrap();
assert_eq!(s.as_str(), "hello");
}
#[test]
fn deserialize_over_max_errors_with_lengths() {
let err =
serde_json::from_str::<ShortString<3>>("\"hello\"").unwrap_err();
let msg = err.to_string();
assert!(msg.contains('3'), "message missing max: {msg}");
assert!(msg.contains('5'), "message missing got: {msg}");
assert!(msg.contains("shorten"), "message missing hint: {msg}");
}
#[test]
fn serialize_is_transparent() {
let s = ShortString::<10>::new("hi").unwrap();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, "\"hi\"");
}
#[test]
fn unicode_counted_by_chars_not_bytes() {
let s: ShortString<5> = serde_json::from_str("\"héllo\"").unwrap();
assert_eq!(s.chars().count(), 5);
}
#[test]
fn deref_works() {
let s = ShortString::<10>::new("test").unwrap();
let upper: String = s.to_uppercase();
assert_eq!(upper, "TEST");
}
#[test]
fn schema_includes_max_length() {
let mut generator = schemars::SchemaGenerator::default();
let schema = <ShortString<256> as schemars::JsonSchema>::json_schema(
&mut generator,
);
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["maxLength"], 256);
assert_eq!(json["type"], "string");
}
#[test]
fn clipped_cuts_at_the_last_sentence_that_fits() {
let text = "One sentence here. Two sentences here. Three runs long";
let (s, cut) = ShortString::<45>::clipped(text);
assert!(cut);
assert_eq!(s.as_str(), "One sentence here. Two sentences here.");
let (s, cut) = ShortString::<100>::clipped(text);
assert!(!cut);
assert_eq!(s.as_str(), text);
}
#[test]
fn clipped_falls_back_to_a_word_and_an_ellipsis() {
let text = "no sentence ends anywhere in this long run of words";
let (s, cut) = ShortString::<20>::clipped(text);
assert!(cut);
assert_eq!(s.as_str(), "no sentence ends…");
assert!(s.chars().count() <= 20);
let (s, _) = ShortString::<30>::clipped(
"Hi. then a very long tail of words that goes on",
);
assert!(s.ends_with('…'), "{s}");
}
}