#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Title(String);
impl serde::Serialize for Title {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl Title {
pub fn new(s: impl Into<String>) -> Result<Self, &'static str> {
let trimmed = s.into().trim().to_string();
if trimmed.is_empty() {
Err("title must not be empty")
} else {
Ok(Title(trimmed))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Title {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl TryFrom<String> for Title {
type Error = &'static str;
fn try_from(s: String) -> Result<Self, Self::Error> {
Title::new(s)
}
}
impl TryFrom<&str> for Title {
type Error = &'static str;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Title::new(s)
}
}
#[cfg(test)]
pub mod strategy {
use super::*;
use proptest::prelude::*;
pub fn arb_title() -> impl Strategy<Value = Title> {
proptest::string::string_regex("[A-Za-z0-9][A-Za-z0-9 ]{0,79}")
.unwrap()
.prop_map(|s| Title::new(s).expect("strategy always produces valid titles"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_accepts_plain_text() {
let t = Title::new("Use Rust").unwrap();
assert_eq!(t.as_str(), "Use Rust");
}
#[test]
fn new_rejects_empty_string() {
assert!(Title::new("").is_err());
}
#[test]
fn new_rejects_whitespace_only() {
assert!(Title::new(" ").is_err());
assert!(Title::new("\t\n").is_err());
}
#[test]
fn new_trims_leading_whitespace() {
let t = Title::new(" Use Rust").unwrap();
assert_eq!(t.as_str(), "Use Rust");
}
#[test]
fn new_trims_trailing_whitespace() {
let t = Title::new("Use Rust ").unwrap();
assert_eq!(t.as_str(), "Use Rust");
}
#[test]
fn new_trims_both_ends() {
let t = Title::new(" Use Rust ").unwrap();
assert_eq!(t.as_str(), "Use Rust");
}
#[test]
fn new_preserves_internal_spaces() {
let t = Title::new("Use Rust today").unwrap();
assert_eq!(t.as_str(), "Use Rust today");
}
#[test]
fn display_roundtrips() {
let t = Title::new("Decision Title").unwrap();
assert_eq!(t.to_string(), "Decision Title");
}
#[test]
fn equality_holds_for_same_value() {
assert_eq!(Title::new("abc").unwrap(), Title::new("abc").unwrap());
}
#[test]
fn equality_normalises_whitespace_padding() {
assert_eq!(Title::new(" abc ").unwrap(), Title::new("abc").unwrap());
}
#[test]
fn try_from_str_roundtrips() {
let t = Title::try_from("hello").unwrap();
assert_eq!(t.as_str(), "hello");
}
#[test]
fn try_from_string_roundtrips() {
let t = Title::try_from("hello".to_string()).unwrap();
assert_eq!(t.as_str(), "hello");
}
#[test]
fn ordering_is_lexicographic() {
let a = Title::new("aaa").unwrap();
let b = Title::new("bbb").unwrap();
assert!(a < b);
}
proptest::proptest! {
#[test]
fn prop_trim_is_idempotent(s in "[A-Za-z0-9][A-Za-z0-9 ]{0,79}") {
let t = Title::new(&s).unwrap();
assert_eq!(Title::new(t.as_str()).unwrap(), t);
}
#[test]
fn prop_display_roundtrips(s in "[A-Za-z0-9][A-Za-z0-9 ]{0,79}") {
let t = Title::new(&s).unwrap();
assert_eq!(t.to_string(), t.as_str());
}
#[test]
fn prop_whitespace_padding_is_normalised(s in "[A-Za-z0-9][A-Za-z0-9 ]{0,79}") {
let padded = format!(" {s} ");
let from_padded = Title::new(&padded).unwrap();
let from_original = Title::new(&s).unwrap();
assert_eq!(from_padded, from_original);
}
}
}