cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// A one-line summary of an issue or decision record.
///
/// # Invariants
/// - **Single line**: rejects any value containing a newline. The
///   description is embedded verbatim in `<meta>` tags and link
///   previews, so it must fit on one line.
/// - **Non-empty after trim**: leading and trailing whitespace are
///   stripped at construction time; a blank value is rejected.
///
/// Pushing both checks into the type system removes ad-hoc validation
/// from the FS adapter and from every use case.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Description(String);

impl serde::Serialize for Description {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.0)
    }
}

impl Description {
    /// Create a `Description` from any string-like value.
    ///
    /// Leading and trailing whitespace are stripped. Returns an error
    /// if the result is empty or if the original value contains a
    /// newline.
    pub fn new(s: impl Into<String>) -> Result<Self, &'static str> {
        let raw = s.into();
        if raw.contains('\n') {
            return Err("description must fit on a single line");
        }
        let trimmed = raw.trim().to_string();
        if trimmed.is_empty() {
            return Err("description must not be empty");
        }
        Ok(Description(trimmed))
    }

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

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

impl TryFrom<String> for Description {
    type Error = &'static str;
    fn try_from(s: String) -> Result<Self, Self::Error> {
        Description::new(s)
    }
}

impl TryFrom<&str> for Description {
    type Error = &'static str;
    fn try_from(s: &str) -> Result<Self, Self::Error> {
        Description::new(s)
    }
}

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

    /// Generates non-empty single-line descriptions. The regex
    /// guarantees at least one alphanumeric character so the result
    /// is never blank after trimming, and excludes newlines.
    pub fn arb_description() -> impl Strategy<Value = Description> {
        proptest::string::string_regex("[A-Za-z0-9][A-Za-z0-9 .,:;!?-]{0,79}")
            .unwrap()
            .prop_map(|s| Description::new(s).expect("strategy always produces valid descriptions"))
    }
}

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

    #[test]
    fn new_accepts_one_line_text() {
        let d = Description::new("A short summary").unwrap();
        assert_eq!(d.as_str(), "A short summary");
    }

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

    #[test]
    fn new_rejects_whitespace_only() {
        assert!(Description::new("   ").is_err());
        assert!(Description::new("\t").is_err());
    }

    #[test]
    fn new_rejects_multiline_value() {
        assert!(Description::new("first line\nsecond line").is_err());
    }

    #[test]
    fn new_trims_padding() {
        let d = Description::new("  hello  ").unwrap();
        assert_eq!(d.as_str(), "hello");
    }

    #[test]
    fn display_roundtrips() {
        let d = Description::new("summary").unwrap();
        assert_eq!(d.to_string(), "summary");
    }

    #[test]
    fn equality_holds_for_same_value() {
        assert_eq!(
            Description::new("abc").unwrap(),
            Description::new("abc").unwrap()
        );
    }

    #[test]
    fn equality_normalises_whitespace_padding() {
        assert_eq!(
            Description::new("  abc  ").unwrap(),
            Description::new("abc").unwrap()
        );
    }

    proptest::proptest! {
        #[test]
        fn prop_trim_is_idempotent(s in "[A-Za-z0-9][A-Za-z0-9 ]{0,79}") {
            let d = Description::new(&s).unwrap();
            assert_eq!(Description::new(d.as_str()).unwrap(), d);
        }
    }
}