cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// The free-text Markdown body of a record or issue.
///
/// # Invariant
/// The inner string is always `trim_end()`-normalised: trailing whitespace and
/// newlines are removed at construction time.  This makes the domain model
/// insensitive to the trailing newline that editors and stdin typically append,
/// which in turn makes the `edit_*_body` no-op detection reliable.
///
/// An empty body is valid — there is no minimum-length constraint.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct Body(String);

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

impl Body {
    /// Create a `Body` from any string-like value.
    ///
    /// Trailing whitespace is stripped.
    pub fn new(s: impl Into<String>) -> Self {
        Body(s.into().trim_end().to_string())
    }

    /// Return the body text.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

impl From<String> for Body {
    fn from(s: String) -> Self {
        Body::new(s)
    }
}

impl From<&str> for Body {
    fn from(s: &str) -> Self {
        Body::new(s)
    }
}

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

    pub fn arb_body() -> impl Strategy<Value = Body> {
        ".*".prop_map(Body::new)
    }
}

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

    #[test]
    fn new_accepts_empty_string() {
        let b = Body::new("");
        assert_eq!(b.as_str(), "");
    }

    #[test]
    fn new_accepts_plain_text() {
        let b = Body::new("## Context\n\nWe need Rust.");
        assert_eq!(b.as_str(), "## Context\n\nWe need Rust.");
    }

    #[test]
    fn new_trims_trailing_newline() {
        let b = Body::new("some content\n");
        assert_eq!(b.as_str(), "some content");
    }

    #[test]
    fn new_trims_multiple_trailing_newlines() {
        let b = Body::new("some content\n\n\n");
        assert_eq!(b.as_str(), "some content");
    }

    #[test]
    fn new_trims_trailing_spaces() {
        let b = Body::new("content   ");
        assert_eq!(b.as_str(), "content");
    }

    #[test]
    fn new_preserves_leading_whitespace() {
        let b = Body::new("  indented");
        assert_eq!(b.as_str(), "  indented");
    }

    #[test]
    fn new_preserves_internal_newlines() {
        let b = Body::new("line1\n\nline2\n");
        assert_eq!(b.as_str(), "line1\n\nline2");
    }

    #[test]
    fn display_roundtrips() {
        let b = Body::new("## Context");
        assert_eq!(b.to_string(), "## Context");
    }

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

    #[test]
    fn equality_normalises_trailing_newline() {
        // "abc\n" and "abc" are the same Body
        assert_eq!(Body::new("abc\n"), Body::new("abc"));
    }

    #[test]
    fn from_str_roundtrips() {
        let b: Body = "hello".into();
        assert_eq!(b.as_str(), "hello");
    }

    #[test]
    fn from_string_roundtrips() {
        let b: Body = "hello".to_string().into();
        assert_eq!(b.as_str(), "hello");
    }

    #[test]
    fn default_is_empty() {
        assert_eq!(Body::default().as_str(), "");
    }

    proptest::proptest! {
        #[test]
        fn prop_trim_end_is_idempotent(s in ".*") {
            let b = Body::new(&s);
            assert_eq!(Body::new(b.as_str()), b);
        }

        #[test]
        fn prop_display_roundtrips(s in ".*") {
            let b = Body::new(&s);
            assert_eq!(b.to_string(), b.as_str());
        }

        #[test]
        fn prop_trailing_newline_is_normalised(s in ".*") {
            let with_nl = format!("{s}\n");
            assert_eq!(Body::new(&s), Body::new(&with_nl));
        }
    }
}