cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use std::fmt;
use std::str::FromStr;

use super::entity_ref::EntityRef;

/// A validated, self-describing reference to a decision record (e.g. `ADR-0024`).
///
/// Wraps [`EntityRef`] for compile-time type safety: a `DecisionRecordRef` and
/// an [`IssueRef`] are distinct types and cannot be interchanged at compile time,
/// even though both carry a prefixed string like `"ADR-0024"` or `"ISSUE-0042"`.
///
/// The prefix is intrinsic to the value — it is **not** injected at display
/// time from an external `id_prefix` parameter.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DecisionRecordRef(EntityRef);

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

/// A validated, self-describing reference to an issue (e.g. `ISSUE-0042`).
///
/// See [`DecisionRecordRef`] for design notes.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IssueRef(EntityRef);

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

// ── DecisionRecordRef ─────────────────────────────────────────────────────────

impl DecisionRecordRef {
    /// Create a `DecisionRecordRef` from a raw string, accepting either schema
    /// shape. Equivalent to [`parse_any`](Self::parse_any).
    pub fn new(s: impl Into<String>) -> anyhow::Result<Self> {
        EntityRef::new(s).map(DecisionRecordRef)
    }

    /// Strict v3 parser. See [`EntityRef::parse_v3`].
    pub fn parse_v3(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_v3(s).map(DecisionRecordRef)
    }

    /// Strict v4 parser. See [`EntityRef::parse_v4`].
    pub fn parse_v4(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_v4(s).map(DecisionRecordRef)
    }

    /// Strict v5 parser. See [`EntityRef::parse_v5`].
    pub fn parse_v5(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_v5(s).map(DecisionRecordRef)
    }

    /// Tolerant parser. See [`EntityRef::parse_any`].
    pub fn parse_any(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_any(s).map(DecisionRecordRef)
    }

    /// Return the inner `EntityRef`.
    pub fn as_entity_ref(&self) -> &EntityRef {
        &self.0
    }

    /// Return the string representation (e.g. `"ADR-0024"`).
    pub fn as_str(&self) -> &str {
        self.0.as_str()
    }

    /// Return the numeric ID part.
    pub fn numeric_id(&self) -> u64 {
        self.0.numeric_id()
    }

    /// Return the suffix part (everything after the prefix).
    pub fn suffix(&self) -> &str {
        self.0.suffix()
    }

    /// Return the prefix part (e.g. `"ADR"`).
    pub fn prefix(&self) -> &str {
        self.0.prefix()
    }
}

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

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

// ── IssueRef ──────────────────────────────────────────────────────────────────

impl IssueRef {
    /// Create an `IssueRef` from a raw string, accepting either schema shape.
    /// Equivalent to [`parse_any`](Self::parse_any).
    pub fn new(s: impl Into<String>) -> anyhow::Result<Self> {
        EntityRef::new(s).map(IssueRef)
    }

    /// Strict v3 parser. See [`EntityRef::parse_v3`].
    pub fn parse_v3(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_v3(s).map(IssueRef)
    }

    /// Strict v4 parser. See [`EntityRef::parse_v4`].
    pub fn parse_v4(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_v4(s).map(IssueRef)
    }

    /// Strict v5 parser. See [`EntityRef::parse_v5`].
    pub fn parse_v5(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_v5(s).map(IssueRef)
    }

    /// Tolerant parser. See [`EntityRef::parse_any`].
    pub fn parse_any(s: &str) -> anyhow::Result<Self> {
        EntityRef::parse_any(s).map(IssueRef)
    }

    /// Return the inner `EntityRef`.
    pub fn as_entity_ref(&self) -> &EntityRef {
        &self.0
    }

    /// Return the string representation (e.g. `"ISSUE-0042"`).
    pub fn as_str(&self) -> &str {
        self.0.as_str()
    }

    /// Return the numeric ID part.
    pub fn numeric_id(&self) -> u64 {
        self.0.numeric_id()
    }

    /// Return the suffix part (everything after the prefix).
    pub fn suffix(&self) -> &str {
        self.0.suffix()
    }

    /// Return the prefix part (e.g. `"ISSUE"`).
    pub fn prefix(&self) -> &str {
        self.0.prefix()
    }
}

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

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

// ── proptest strategies ───────────────────────────────────────────────────────

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

    pub fn decision_record_ref() -> impl Strategy<Value = DecisionRecordRef> {
        (
            proptest::sample::select(vec!["ADR", "DDR", "GDDR"]),
            1u32..10_000,
        )
            .prop_map(|(prefix, n)| DecisionRecordRef::new(format!("{prefix}-{n:04}")).unwrap())
    }

    pub fn issue_ref() -> impl Strategy<Value = IssueRef> {
        (1u32..10_000).prop_map(|n| IssueRef::new(format!("ISSUE-{n:04}")).unwrap())
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    #[test]
    fn decision_record_ref_accepts_valid() {
        let r = DecisionRecordRef::new("ADR-0001").unwrap();
        assert_eq!(r.as_str(), "ADR-0001");
        assert_eq!(r.prefix(), "ADR");
        assert_eq!(r.numeric_id(), 1);
    }

    #[test]
    fn issue_ref_accepts_valid() {
        let r = IssueRef::new("ISSUE-0042").unwrap();
        assert_eq!(r.as_str(), "ISSUE-0042");
        assert_eq!(r.prefix(), "ISSUE");
        assert_eq!(r.numeric_id(), 42);
    }

    #[test]
    fn decision_record_ref_rejects_bare_integer() {
        assert!(DecisionRecordRef::new("42").is_err());
        assert!(DecisionRecordRef::new("0001").is_err());
    }

    #[test]
    fn issue_ref_rejects_bare_integer() {
        assert!(IssueRef::new("42").is_err());
    }

    #[test]
    fn display_roundtrips_decision_record_ref() {
        let r = DecisionRecordRef::new("ADR-0007").unwrap();
        assert_eq!(r.to_string(), "ADR-0007");
    }

    #[test]
    fn display_roundtrips_issue_ref() {
        let r = IssueRef::new("ISSUE-0007").unwrap();
        assert_eq!(r.to_string(), "ISSUE-0007");
    }

    #[test]
    fn decision_record_ref_and_issue_ref_are_distinct_types() {
        // This test simply ensures the two types do not alias at compile time.
        // A DecisionRecordRef cannot be assigned to an IssueRef variable.
        let _dr: DecisionRecordRef = DecisionRecordRef::new("ADR-0001").unwrap();
        let _ir: IssueRef = IssueRef::new("ISSUE-0001").unwrap();
    }

    #[test]
    fn ordering_is_by_full_string() {
        let a = DecisionRecordRef::new("ADR-0001").unwrap();
        let b = DecisionRecordRef::new("ADR-0002").unwrap();
        assert!(a < b);
    }

    proptest! {
        #[test]
        fn prop_decision_record_ref_display_roundtrips(
            r in strategy::decision_record_ref()
        ) {
            prop_assert_eq!(r.to_string().parse::<DecisionRecordRef>().unwrap(), r);
        }

        #[test]
        fn prop_issue_ref_display_roundtrips(r in strategy::issue_ref()) {
            prop_assert_eq!(r.to_string().parse::<IssueRef>().unwrap(), r);
        }
    }
}