planter-core 0.0.7

Domain logic for PlanTer, a project management application
Documentation
use nutype::nutype;

/// The longest a [`Title`] may be, in characters.
const TITLE_LEN: usize = 100;

/// A human-readable name for a domain entity: a resource, and in time a task or project.
///
/// Trimmed of surrounding whitespace, required to be non-empty, and capped at 100 characters.
/// Unlike [`NameString`](crate::person::NameString) it places no restriction on which
/// characters it contains: `"Stimpack (batch #7)"` is a fine resource title.
#[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)]
/// Utilities to test titles.
pub mod test_utils {
    use super::Title;
    use proptest::prelude::*;

    /// A strategy for a valid [`Title`].
    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;

    /// A char `trim()` never strips, so it's safe to use as title content without disturbing a
    /// length or trimming assertion.
    fn non_whitespace_char() -> impl Strategy<Value = char> {
        any::<char>().prop_filter("no whitespace", |c| !c.is_whitespace())
    }

    /// A run of zero or more whitespace characters, including the empty string.
    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());
        }
    }
}