cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// A person identifier assigned to an issue (email or username).
///
/// Accepts any non-empty string without whitespace.
/// No format enforcement beyond that — teams use different identity formats.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Assignee(String);

impl Assignee {
    pub fn new(s: &str) -> anyhow::Result<Self> {
        let trimmed = s.trim();
        if trimmed.is_empty() {
            anyhow::bail!("assignee cannot be empty");
        }
        if trimmed.chars().any(|c| c.is_whitespace()) {
            anyhow::bail!("assignee '{trimmed}' must not contain whitespace");
        }
        Ok(Assignee(trimmed.to_owned()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for Assignee {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::str::FromStr for Assignee {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> anyhow::Result<Self> {
        Assignee::new(s)
    }
}

#[cfg(test)]
pub mod strategy {
    use super::*;
    use proptest::prelude::*;

    pub fn assignee() -> impl Strategy<Value = Assignee> {
        "[a-z0-9.@_-]{1,30}".prop_map(|s| Assignee::new(&s).unwrap())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn accepts_simple_username() {
        assert!(Assignee::new("alice").is_ok());
    }

    #[test]
    fn accepts_email() {
        assert!(Assignee::new("alice@example.com").is_ok());
    }

    #[test]
    fn accepts_dotted_username() {
        assert!(Assignee::new("alice.bob").is_ok());
    }

    #[test]
    fn rejects_empty() {
        assert!(Assignee::new("").is_err());
    }

    #[test]
    fn rejects_blank() {
        assert!(Assignee::new("   ").is_err());
    }

    #[test]
    fn rejects_whitespace() {
        assert!(Assignee::new("alice bob").is_err());
    }

    #[test]
    fn trims_leading_trailing_whitespace() {
        let a = Assignee::new("  alice  ").unwrap();
        assert_eq!(a.as_str(), "alice");
    }

    #[test]
    fn display_matches_as_str() {
        let a = Assignee::new("alice").unwrap();
        assert_eq!(a.to_string(), "alice");
    }

    #[test]
    fn from_str_roundtrips() {
        let a: Assignee = "alice@example.com".parse().unwrap();
        assert_eq!(a.as_str(), "alice@example.com");
    }

    proptest! {
        #[test]
        fn prop_clone_equals_original(a in strategy::assignee()) {
            prop_assert_eq!(a.clone(), a);
        }

        #[test]
        fn prop_as_str_is_non_empty(a in strategy::assignee()) {
            prop_assert!(!a.as_str().is_empty());
        }

        #[test]
        fn prop_no_whitespace(a in strategy::assignee()) {
            prop_assert!(!a.as_str().chars().any(|c| c.is_whitespace()));
        }
    }
}