cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use serde::{Deserialize, Serialize};

use super::category::StatusCategory;

/// Five-bucket histogram of a composite issue's direct children by
/// [`StatusCategory`].
///
/// `Unknown` children — those whose status has no configured
/// category — are not counted in any bucket; they would join as
/// the chain's bottom (`U ⊔ x = x`) and so do not shift the
/// derived [`StatusCategory`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RollupHistogram {
    pub queued: u32,
    pub active: u32,
    pub stalled: u32,
    pub resolved: u32,
    pub cancelled: u32,
}

impl RollupHistogram {
    /// Build the histogram by counting each input category. `Unknown`
    /// inputs are silently dropped (see type-level note).
    pub fn from_categories(it: impl IntoIterator<Item = StatusCategory>) -> Self {
        let mut h = Self::default();
        for cat in it {
            match cat {
                StatusCategory::Queued => h.queued += 1,
                StatusCategory::Active => h.active += 1,
                StatusCategory::Stalled => h.stalled += 1,
                StatusCategory::Resolved => h.resolved += 1,
                StatusCategory::Cancelled => h.cancelled += 1,
                StatusCategory::Unknown => {}
            }
        }
        h
    }

    /// Total number of counted children (excludes `Unknown`).
    pub fn total(&self) -> u32 {
        self.queued + self.active + self.stalled + self.resolved + self.cancelled
    }

    /// Derive the rollup [`StatusCategory`] — the highest-rank bucket
    /// with a non-zero count on the chain
    /// `Cancelled < Resolved < Queued < Stalled < Active`.
    ///
    /// Returns `Unknown` if every bucket is zero (every child was
    /// `Unknown` or there are no children — though the caller should
    /// have short-circuited before getting here).
    pub fn category(&self) -> StatusCategory {
        let ranked = [
            (self.active, StatusCategory::Active),
            (self.stalled, StatusCategory::Stalled),
            (self.queued, StatusCategory::Queued),
            (self.resolved, StatusCategory::Resolved),
            (self.cancelled, StatusCategory::Cancelled),
        ];
        ranked
            .into_iter()
            .find(|(n, _)| *n > 0)
            .map(|(_, cat)| cat)
            .unwrap_or(StatusCategory::Unknown)
    }
}

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

    prop_compose! {
        pub fn rollup_histogram()(
            queued in 0u32..=10,
            active in 0u32..=10,
            stalled in 0u32..=10,
            resolved in 0u32..=10,
            cancelled in 0u32..=10,
        ) -> RollupHistogram {
            RollupHistogram { queued, active, stalled, resolved, cancelled }
        }
    }
}

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

    use StatusCategory::{
        Active as A, Cancelled as C, Queued as Q, Resolved as R, Stalled as S, Unknown as U,
    };

    proptest! {
        #[test]
        fn total_equals_sum_of_buckets(h in strategy::rollup_histogram()) {
            prop_assert_eq!(
                h.total(),
                h.queued + h.active + h.stalled + h.resolved + h.cancelled
            );
        }

        #[test]
        fn empty_histogram_categorises_as_unknown(_ in Just(())) {
            prop_assert_eq!(RollupHistogram::default().category(), StatusCategory::Unknown);
        }
    }

    #[test]
    fn from_categories_counts_each_bucket() {
        let h = RollupHistogram::from_categories([Q, Q, A, S, S, S, R, C]);
        assert_eq!(h.queued, 2);
        assert_eq!(h.active, 1);
        assert_eq!(h.stalled, 3);
        assert_eq!(h.resolved, 1);
        assert_eq!(h.cancelled, 1);
    }

    #[test]
    fn unknown_children_are_not_counted() {
        let h = RollupHistogram::from_categories([U, U, Q, S]);
        assert_eq!(h.total(), 2, "Unknown is dropped from the histogram");
        assert_eq!(h.queued, 1);
        assert_eq!(h.stalled, 1);
    }

    #[test]
    fn empty_iterator_is_all_zero() {
        let h = RollupHistogram::from_categories(std::iter::empty());
        assert_eq!(h.total(), 0);
        assert_eq!(h.category(), U);
    }

    #[test]
    fn category_picks_highest_rank_non_zero_bucket() {
        // Order on the chain: A > S > Q > R > C.
        assert_eq!(
            RollupHistogram::from_categories([A, S, Q, R, C]).category(),
            A
        );
        assert_eq!(RollupHistogram::from_categories([S, Q, R, C]).category(), S);
        assert_eq!(RollupHistogram::from_categories([Q, R, C]).category(), Q);
        assert_eq!(RollupHistogram::from_categories([R, C]).category(), R);
        assert_eq!(RollupHistogram::from_categories([C]).category(), C);
    }

    /// The histogram-derived category must agree with folding the
    /// children directly under [`StatusCategory::join`]. The two
    /// surfaces have to match exactly — they are the same projection
    /// just stored differently.
    #[test]
    fn category_matches_direct_fold() {
        let cases: &[&[StatusCategory]] = &[
            &[Q, Q, S],
            &[Q, Q, Q, A],
            &[S, S, R],
            &[Q, R],
            &[Q, C],
            &[R, R, R, R, R, R, R, R, R, S],
            &[R, R, R, R, R, R, R, R, R, C],
            &[R, C, C],
            &[C, C, C, C],
            &[Q, A, S, R, C],
            &[U, U, Q, Q],
        ];
        for children in cases {
            let from_histogram =
                RollupHistogram::from_categories(children.iter().copied()).category();
            let from_fold = StatusCategory::fold_categories(children.iter().copied()).unwrap_or(U);
            assert_eq!(
                from_histogram, from_fold,
                "histogram/fold mismatch on {children:?}: \
                 histogram={from_histogram}, fold={from_fold}"
            );
        }
    }
}