cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::record_ref::IssueRef;

/// The type of relationship between two issues.
///
/// This enum is the single source of truth for issue relationship types.
/// User-facing CLI flags are generated from
/// `IssueRelationship::user_writable()` — do not hard-code relationship
/// names in the CLI layer (see ADR-0010). Serialized manually via
/// `as_str()` to produce kebab-case (`blocks`, `blocked-by`, `parent-of`).
///
/// All four verbs come in mutual-inverse pairs: `blocks` ↔ `blocked-by`
/// and `parent-of` ↔ `child-of`. The user writes whichever verb matches
/// the side they are standing on; the link cascade stores the inverse on
/// the target per DDR-018QWJVHRH35B (storage = both sides).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueRelationship {
    /// This issue must be resolved before the target can progress.
    Blocks,
    /// This issue cannot progress until the target is resolved.
    BlockedBy,
    /// This issue is the parent of the target — a structural breakdown link.
    ParentOf,
    /// Inverse of `ParentOf` — written into the target on link add.
    ChildOf,
}

impl IssueRelationship {
    pub fn as_str(&self) -> &'static str {
        match self {
            IssueRelationship::Blocks => "blocks",
            IssueRelationship::BlockedBy => "blocked-by",
            IssueRelationship::ParentOf => "parent-of",
            IssueRelationship::ChildOf => "child-of",
        }
    }

    /// All known relationship types — including system-only inverse pointers.
    /// Used to enumerate every legal verb a frontmatter can carry.
    pub fn all() -> &'static [IssueRelationship] {
        &[
            IssueRelationship::Blocks,
            IssueRelationship::BlockedBy,
            IssueRelationship::ParentOf,
            IssueRelationship::ChildOf,
        ]
    }

    /// User-writable relationship types — drives `--<flag>` CLI generation.
    /// All four verbs are user-writable: each side of a mutual-inverse pair
    /// is equally a legitimate way to express the same edge.
    pub fn user_writable() -> &'static [IssueRelationship] {
        &[
            IssueRelationship::Blocks,
            IssueRelationship::BlockedBy,
            IssueRelationship::ParentOf,
            IssueRelationship::ChildOf,
        ]
    }

    /// `true` if this relationship defines a vertical breakdown axis where
    /// cycles must be rejected and a child has at most one parent. Used by
    /// `link_issue` and `cartu issue check` to apply the relevant invariants
    /// without naming the variant directly.
    pub fn is_hierarchical(&self) -> bool {
        matches!(self, IssueRelationship::ParentOf)
    }

    /// The inverse relationship written into the target on link add. All
    /// four verbs come in mutual-inverse pairs; both sides of a pair are
    /// user-writable and denote the same edge.
    pub fn inverse(&self) -> IssueRelationship {
        match self {
            IssueRelationship::Blocks => IssueRelationship::BlockedBy,
            IssueRelationship::BlockedBy => IssueRelationship::Blocks,
            IssueRelationship::ParentOf => IssueRelationship::ChildOf,
            IssueRelationship::ChildOf => IssueRelationship::ParentOf,
        }
    }
}

impl std::str::FromStr for IssueRelationship {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "blocks" => Ok(IssueRelationship::Blocks),
            "blocked-by" => Ok(IssueRelationship::BlockedBy),
            "parent-of" => Ok(IssueRelationship::ParentOf),
            "child-of" => Ok(IssueRelationship::ChildOf),
            other => Err(anyhow::anyhow!(
                "unknown issue relationship type: '{other}'"
            )),
        }
    }
}

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

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

/// A directed link from one issue to another. Mono-kind by construction:
/// typed verbs (`blocks`, `blocked-by`, `parent-of`) only make sense issue↔issue.
/// Cross-kind "see also" pointers live in the separate `relates:`
/// frontmatter field. See DDR-018QWJVHRH35B and ISSUE-018P03NSC7VNQ.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct IssueLink {
    pub target: IssueRef,
    pub relationship: IssueRelationship,
}

// ── IssueLinks ───────────────────────────────────────────────────────────────

/// An ordered collection of directed links attached to an issue.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct IssueLinks(Vec<IssueLink>);

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

impl IssueLinks {
    /// Create an empty collection.
    pub fn new() -> Self {
        Self::default()
    }

    /// Add a link.
    pub fn push(&mut self, link: IssueLink) {
        self.0.push(link);
    }

    /// Return a new collection with `link` appended. Pure transform aligned
    /// with ADR-00000000218SQ — does not mutate.
    pub fn with_added(&self, link: IssueLink) -> Self {
        let mut out = self.clone();
        out.0.push(link);
        out
    }

    /// Return a new collection with the first matching `(target, relationship)`
    /// link removed, or `None` if no such link is present. Pure transform.
    pub fn with_removed(&self, link: &IssueLink) -> Option<Self> {
        let pos = self
            .0
            .iter()
            .position(|l| l.target == link.target && l.relationship == link.relationship)?;
        let mut out = self.clone();
        out.0.remove(pos);
        Some(out)
    }

    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    pub fn len(&self) -> usize {
        self.0.len()
    }

    pub fn iter(&self) -> impl Iterator<Item = &IssueLink> {
        self.0.iter()
    }
}

impl IntoIterator for IssueLinks {
    type Item = IssueLink;
    type IntoIter = std::vec::IntoIter<IssueLink>;

    fn into_iter(self) -> Self::IntoIter {
        self.0.into_iter()
    }
}

impl<'a> IntoIterator for &'a IssueLinks {
    type Item = &'a IssueLink;
    type IntoIter = std::slice::Iter<'a, IssueLink>;

    fn into_iter(self) -> Self::IntoIter {
        self.0.iter()
    }
}

impl FromIterator<IssueLink> for IssueLinks {
    fn from_iter<I: IntoIterator<Item = IssueLink>>(iter: I) -> Self {
        IssueLinks(iter.into_iter().collect())
    }
}

impl std::ops::Index<usize> for IssueLinks {
    type Output = IssueLink;
    fn index(&self, i: usize) -> &Self::Output {
        &self.0[i]
    }
}

#[cfg(test)]
pub mod strategy {
    use super::{IssueLink, IssueLinks, IssueRelationship};
    use crate::domain::model::record_ref::strategy::issue_ref;
    use proptest::prelude::*;

    /// Generate any `IssueRelationship` variant.
    pub fn issue_relationship() -> impl Strategy<Value = IssueRelationship> {
        prop_oneof![
            Just(IssueRelationship::Blocks),
            Just(IssueRelationship::BlockedBy),
            Just(IssueRelationship::ParentOf),
            Just(IssueRelationship::ChildOf),
        ]
    }

    prop_compose! {
        /// Generate an `IssueLink` over an arbitrary target and relationship.
        pub fn issue_link()(
            target in issue_ref(),
            relationship in issue_relationship(),
        ) -> IssueLink {
            IssueLink { target, relationship }
        }
    }

    /// Generate an `IssueLinks` collection of 0..=8 links.
    pub fn issue_links() -> impl Strategy<Value = IssueLinks> {
        proptest::collection::vec(issue_link(), 0..=8).prop_map(|v| v.into_iter().collect())
    }
}

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

    fn sample_issue_link() -> IssueLink {
        IssueLink {
            target: IssueRef::new("ISSUE-0001").unwrap(),
            relationship: IssueRelationship::Blocks,
        }
    }

    #[test]
    fn issue_relationship_as_str_roundtrips() {
        for rel in IssueRelationship::all() {
            let s = rel.as_str();
            let parsed: IssueRelationship = s.parse().unwrap();
            assert_eq!(&parsed, rel);
        }
    }

    #[test]
    fn issue_relationship_all_contains_all_variants() {
        let all = IssueRelationship::all();
        assert!(all.contains(&IssueRelationship::Blocks));
        assert!(all.contains(&IssueRelationship::BlockedBy));
        assert!(all.contains(&IssueRelationship::ParentOf));
        assert!(all.contains(&IssueRelationship::ChildOf));
    }

    #[test]
    fn all_variants_are_user_writable() {
        let uw = IssueRelationship::user_writable();
        assert_eq!(uw.len(), 4);
        assert!(uw.contains(&IssueRelationship::Blocks));
        assert!(uw.contains(&IssueRelationship::BlockedBy));
        assert!(uw.contains(&IssueRelationship::ParentOf));
        assert!(uw.contains(&IssueRelationship::ChildOf));
    }

    #[test]
    fn inverses_are_mutual() {
        for rel in IssueRelationship::all() {
            assert_eq!(&rel.inverse().inverse(), rel);
        }
        assert_eq!(
            IssueRelationship::Blocks.inverse(),
            IssueRelationship::BlockedBy
        );
        assert_eq!(
            IssueRelationship::ParentOf.inverse(),
            IssueRelationship::ChildOf
        );
    }

    #[test]
    fn parent_of_is_the_only_hierarchical_relationship() {
        assert!(IssueRelationship::ParentOf.is_hierarchical());
        assert!(!IssueRelationship::Blocks.is_hierarchical());
    }

    #[test]
    fn issue_relationship_from_str_rejects_unknown() {
        assert!("unknown".parse::<IssueRelationship>().is_err());
    }

    #[test]
    fn issue_relationship_display_matches_as_str() {
        for rel in IssueRelationship::all() {
            assert_eq!(rel.to_string(), rel.as_str());
        }
    }

    #[test]
    fn issue_links_new_is_empty() {
        assert!(IssueLinks::new().is_empty());
    }

    #[test]
    fn issue_links_push_makes_non_empty() {
        let mut l = IssueLinks::new();
        l.push(sample_issue_link());
        assert!(!l.is_empty());
    }

    #[test]
    fn issue_links_iter_yields_items() {
        let mut l = IssueLinks::new();
        l.push(sample_issue_link());
        assert_eq!(l.iter().count(), 1);
    }

    #[test]
    fn issue_links_into_iter_ref_works_in_for_loop() {
        let mut l = IssueLinks::new();
        l.push(sample_issue_link());
        let mut count = 0;
        for _ in &l {
            count += 1;
        }
        assert_eq!(count, 1);
    }

    #[test]
    fn issue_links_from_iter_collects() {
        let items = vec![sample_issue_link()];
        let l: IssueLinks = items.into_iter().collect();
        assert_eq!(l.iter().count(), 1);
    }

    use super::strategy;

    proptest::proptest! {
        #[test]
        fn prop_relationship_as_str_roundtrips(rel in strategy::issue_relationship()) {
            proptest::prop_assert_eq!(rel.as_str().parse::<IssueRelationship>().unwrap(), rel);
        }

        #[test]
        fn prop_link_preserves_fields(link in strategy::issue_link()) {
            let cloned = link.clone();
            proptest::prop_assert_eq!(&cloned.target, &link.target);
            proptest::prop_assert_eq!(&cloned.relationship, &link.relationship);
        }

        #[test]
        fn prop_links_len_matches_iter_count(links in strategy::issue_links()) {
            proptest::prop_assert_eq!(links.len(), links.iter().count());
        }
    }
}