use nutype::nutype;
const TITLE_LEN: usize = 100;
#[nutype(
sanitize(trim),
validate(not_empty, len_char_max = TITLE_LEN),
derive(Debug, Eq, PartialEq, Clone, Display, Deref, AsRef, FromStr, TryFrom),
cfg_attr(feature = "serde", derive(Serialize, Deserialize)),
)]
pub struct Title(String);
#[cfg(test)]
pub mod test_utils {
use super::Title;
use proptest::prelude::*;
pub fn title_strategy() -> impl Strategy<Value = Title> {
r"[a-zA-Z0-9 ()#._/-]{1,100}"
.prop_map(|s| s.trim().to_owned())
.prop_filter("non-empty after trim", |s| !s.is_empty())
.prop_map(|s| Title::try_new(s).expect("strategy builds valid titles"))
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::Title;
fn non_whitespace_char() -> impl Strategy<Value = char> {
any::<char>().prop_filter("no whitespace", |c| !c.is_whitespace())
}
fn whitespace_run() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(vec![' ', '\t', '\n']), 0..5)
.prop_map(|chars| chars.into_iter().collect())
}
proptest! {
#[test]
fn any_non_whitespace_content_up_to_the_cap_round_trips(
chars in prop::collection::vec(non_whitespace_char(), 1..=100),
) {
let s: String = chars.into_iter().collect();
let title = Title::try_new(s.clone()).unwrap();
prop_assert_eq!(title.as_ref(), s.as_str());
}
#[test]
fn content_past_the_char_length_cap_is_rejected(
chars in prop::collection::vec(non_whitespace_char(), 101..=150),
) {
let s: String = chars.into_iter().collect();
prop_assert!(Title::try_new(s).is_err());
}
#[test]
fn any_surrounding_whitespace_is_trimmed(
core in prop::collection::vec(non_whitespace_char(), 1..=50),
leading in whitespace_run(),
trailing in whitespace_run(),
) {
let core: String = core.into_iter().collect();
let title = Title::try_new(format!("{leading}{core}{trailing}")).unwrap();
prop_assert_eq!(title.as_ref(), core.as_str());
}
#[test]
fn whitespace_only_input_is_rejected(input in whitespace_run()) {
prop_assert!(Title::try_new(input).is_err());
}
}
}