cartulary 0.3.0-alpha.1

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

/// A flat, deduplicated list of cross-kind "see also" pointers attached to
/// an issue or decision record. Distinct from typed links (`supersedes`,
/// `amends`, `parent-of`, `blocks`): no behaviour, no cascade, no
/// directional rule beyond the asymmetry of storage.
///
/// Invariants enforced here: no duplicates, ordered by insertion. The
/// no-self-reference invariant is checked at the use-case layer (where
/// the holder's id is known) rather than in this type.
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Relates(Vec<EntityRef>);

impl Relates {
    pub fn new() -> Self {
        Self::default()
    }

    /// Silently ignores duplicates. Private so external construction goes
    /// through `FromIterator` and mutation by use cases through
    /// `with` / `without` (FCIS).
    fn push(&mut self, target: EntityRef) {
        if !self.0.contains(&target) {
            self.0.push(target);
        }
    }

    fn remove(&mut self, target: &EntityRef) {
        self.0.retain(|t| t != target);
    }

    pub fn contains(&self, target: &EntityRef) -> bool {
        self.0.contains(target)
    }

    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 = &EntityRef> {
        self.0.iter()
    }
}

/// Outcome of [`Relates::with`] — captures the verdict of a pure relate
/// addition so the calling shell does not have to recompute it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelatesAddition {
    /// The target was already present; the original list is unchanged.
    Unchanged,
    /// The target was added; `list` is the new value.
    Added { added: EntityRef, list: Relates },
}

/// Outcome of [`Relates::without`] — symmetric to [`RelatesAddition`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelatesRemoval {
    /// The target was not present; the original list is unchanged.
    Unchanged,
    /// The target was removed; `list` is the new value.
    Removed { removed: EntityRef, list: Relates },
}

impl Relates {
    /// Pure add: return a verdict capturing whether the list changed and,
    /// if so, the new list. Does not mutate.
    pub fn with(&self, target: EntityRef) -> RelatesAddition {
        if self.contains(&target) {
            return RelatesAddition::Unchanged;
        }
        let mut list = self.clone();
        list.push(target.clone());
        RelatesAddition::Added {
            added: target,
            list,
        }
    }

    /// Pure remove: return a verdict capturing whether the list changed and,
    /// if so, the new list. Does not mutate.
    pub fn without(&self, target: &EntityRef) -> RelatesRemoval {
        if !self.contains(target) {
            return RelatesRemoval::Unchanged;
        }
        let mut list = self.clone();
        list.remove(target);
        RelatesRemoval::Removed {
            removed: target.clone(),
            list,
        }
    }
}

impl FromIterator<EntityRef> for Relates {
    fn from_iter<I: IntoIterator<Item = EntityRef>>(iter: I) -> Self {
        let mut list = Relates::new();
        for r in iter {
            list.push(r);
        }
        list
    }
}

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

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

#[cfg(test)]
pub mod strategy {
    use super::Relates;
    use crate::domain::model::entity_ref::strategy::entity_ref;
    use proptest::prelude::*;

    /// Generate a `Relates` of 0 to 5 valid entity refs. Duplicates are
    /// filtered by the `push` invariant, so the list is naturally
    /// deduplicated.
    pub fn relates() -> impl Strategy<Value = Relates> {
        proptest::collection::vec(entity_ref(), 0..5).prop_map(Relates::from_iter)
    }
}

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

    fn r(s: &str) -> EntityRef {
        EntityRef::new(s).unwrap()
    }

    fn list_of(items: &[&str]) -> Relates {
        items.iter().map(|s| r(s)).collect()
    }

    #[test]
    fn push_is_idempotent_on_duplicates() {
        let mut list = Relates::new();
        let target = r("ADR-0001");
        list.push(target.clone());
        list.push(target);
        assert_eq!(list.iter().count(), 1);
    }

    #[test]
    fn remove_is_noop_on_absent_target() {
        let mut list = Relates::new();
        list.push(r("ADR-0001"));
        list.remove(&r("ADR-0099"));
        assert_eq!(list.iter().count(), 1);
    }

    #[test]
    fn iter_preserves_insertion_order() {
        let list = list_of(&["ADR-0001", "ISSUE-0002", "DDR-0003"]);
        let names: Vec<&str> = list.iter().map(EntityRef::as_str).collect();
        assert_eq!(names, vec!["ADR-0001", "ISSUE-0002", "DDR-0003"]);
    }

    #[test]
    fn with_is_unchanged_when_target_already_present() {
        let list = list_of(&["ADR-0001"]);
        assert_eq!(list.with(r("ADR-0001")), RelatesAddition::Unchanged);
    }

    #[test]
    fn with_appends_when_target_is_new() {
        let list = list_of(&["ADR-0001"]);
        let RelatesAddition::Added { added, list: new } = list.with(r("ADR-0002")) else {
            panic!("expected Added");
        };
        assert_eq!(added, r("ADR-0002"));
        assert_eq!(new, list_of(&["ADR-0001", "ADR-0002"]));
    }

    #[test]
    fn with_does_not_mutate_self() {
        let list = list_of(&["ADR-0001"]);
        let _ = list.with(r("ADR-0002"));
        assert_eq!(list, list_of(&["ADR-0001"]));
    }

    #[test]
    fn without_is_unchanged_when_target_absent() {
        let list = list_of(&["ADR-0001"]);
        assert_eq!(list.without(&r("ADR-0099")), RelatesRemoval::Unchanged);
    }

    #[test]
    fn without_drops_the_target() {
        let list = list_of(&["ADR-0001", "ADR-0002", "ADR-0003"]);
        let RelatesRemoval::Removed { removed, list: new } = list.without(&r("ADR-0002")) else {
            panic!("expected Removed");
        };
        assert_eq!(removed, r("ADR-0002"));
        assert_eq!(new, list_of(&["ADR-0001", "ADR-0003"]));
    }

    #[test]
    fn without_does_not_mutate_self() {
        let list = list_of(&["ADR-0001", "ADR-0002"]);
        let _ = list.without(&r("ADR-0001"));
        assert_eq!(list, list_of(&["ADR-0001", "ADR-0002"]));
    }

    proptest::proptest! {
        #[test]
        fn strategy_produces_dedup_list(list in strategy::relates()) {
            let mut seen = Vec::new();
            for target in list.iter() {
                proptest::prop_assert!(!seen.contains(&target), "duplicate in list");
                seen.push(target);
            }
        }
    }
}