cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// The human-readable title of a record or issue.
///
/// # Invariant
/// The inner string is always non-empty after `trim()`-normalisation:
/// leading and trailing whitespace are stripped at construction time, and a
/// blank (whitespace-only) title is rejected.
///
/// This pushes the "title must not be empty" validation into the type system,
/// eliminating the need for ad-hoc checks in every use-case and validation
/// function.
#[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 {
    /// Create a `Title` from any string-like value.
    ///
    /// Leading and trailing whitespace are stripped.  Returns an error if the
    /// result is empty.
    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::*;

    /// Generates non-empty titles with alphanumeric characters and spaces.
    /// The regex guarantees at least one alphanumeric character so the
    /// generated string is never blank after trimming.
    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);
        }
    }
}