cartulary 0.3.0-alpha.1

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

use serde::{Deserialize, Serialize};

/// Five-state Lean state map for a status value. Powers flow-
/// efficiency / throughput reporting and the composite-issue rollup.
///
/// - [`Queued`] — pre-work: created, not yet started. Lead-time bucket.
/// - [`Active`] — value-adding: work is happening right now.
/// - [`Stalled`] — started, currently paused. Waiting on input,
///   review, or an external dependency. Flow-efficiency bottleneck.
/// - [`Resolved`] — terminal: completed successfully.
/// - [`Cancelled`] — terminal: abandoned (won't fix, duplicate, out of
///   scope, superseded).
/// - [`Unknown`] — no category configured; internal fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum StatusCategory {
    /// Pre-work: created, not yet started.
    Queued,
    /// Value-adding: work is happening right now.
    Active,
    /// Started, currently paused.
    Stalled,
    /// Terminal: completed successfully.
    Resolved,
    /// Terminal: abandoned.
    Cancelled,
    /// No category configured for this status.
    #[default]
    Unknown,
}

/// Failure to parse a category token from `cartulary.toml`. The
/// tokens `"ongoing"` and `"pending"` are rejected: their
/// replacement depends on the workflow position (queued vs stalled)
/// and can't be guessed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseStatusCategoryError {
    /// A pre-enrichment token that no longer maps to a single
    /// category.
    LegacyToken {
        token: String,
        replacement_hint: &'static str,
    },
}

impl fmt::Display for ParseStatusCategoryError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ParseStatusCategoryError::LegacyToken {
                token,
                replacement_hint,
            } => write!(
                f,
                "category {token:?} is no longer accepted; \
                 replace with {replacement_hint}"
            ),
        }
    }
}

impl std::error::Error for ParseStatusCategoryError {}

impl StatusCategory {
    /// Parse from the string representation used in `cartulary.toml`.
    ///
    /// Recognised tokens: `"queued"`, `"active"`, `"stalled"`,
    /// `"resolved"`, `"cancelled"`. Returns `Ok(Unknown)` for absent
    /// or unrecognised values, so users can leave the field empty.
    /// Returns `Err(LegacyToken)` for `"ongoing"` and `"pending"`.
    pub fn parse(s: &str) -> Result<Self, ParseStatusCategoryError> {
        match s {
            "queued" => Ok(StatusCategory::Queued),
            "active" => Ok(StatusCategory::Active),
            "stalled" => Ok(StatusCategory::Stalled),
            "resolved" => Ok(StatusCategory::Resolved),
            "cancelled" => Ok(StatusCategory::Cancelled),
            "ongoing" => Err(ParseStatusCategoryError::LegacyToken {
                token: s.to_owned(),
                replacement_hint: "\"active\"",
            }),
            "pending" => Err(ParseStatusCategoryError::LegacyToken {
                token: s.to_owned(),
                replacement_hint: "\"queued\" (pre-work) or \"stalled\" (post-start wait)",
            }),
            _ => Ok(StatusCategory::Unknown),
        }
    }

    /// Return the canonical string representation.
    pub fn as_str(self) -> &'static str {
        match self {
            StatusCategory::Queued => "queued",
            StatusCategory::Active => "active",
            StatusCategory::Stalled => "stalled",
            StatusCategory::Resolved => "resolved",
            StatusCategory::Cancelled => "cancelled",
            StatusCategory::Unknown => "unknown",
        }
    }

    /// Return `true` if this category is meaningful for time-based
    /// metrics.
    pub fn is_known(self) -> bool {
        !matches!(self, StatusCategory::Unknown)
    }

    /// Return `true` if this category is terminal — no further
    /// transitions expected.
    pub fn is_terminal(self) -> bool {
        matches!(self, StatusCategory::Resolved | StatusCategory::Cancelled)
    }

    /// Total-chain rank used by the rollup join:
    /// `Unknown < Cancelled < Resolved < Queued < Stalled < Active`.
    ///
    /// Internal — the public surface is [`join`] and [`fold_categories`].
    fn rank(self) -> u8 {
        match self {
            StatusCategory::Unknown => 0,
            StatusCategory::Cancelled => 1,
            StatusCategory::Resolved => 2,
            StatusCategory::Queued => 3,
            StatusCategory::Stalled => 4,
            StatusCategory::Active => 5,
        }
    }

    /// Join two categories on the rollup chain `C ≤ R ≤ Q ≤ S ≤ A`.
    /// `Unknown` is bottom: `U ⊔ x = x`.
    ///
    /// Associative, commutative, idempotent. The composite-issue
    /// rollup is the fold of its direct children's categories under
    /// this operator.
    pub fn join(a: Self, b: Self) -> Self {
        if a.rank() >= b.rank() {
            a
        } else {
            b
        }
    }

    /// Fold an iterator of categories under [`join`]. Returns `None`
    /// for the empty iterator — a composite issue with zero children
    /// has no rollup.
    pub fn fold_categories(it: impl IntoIterator<Item = Self>) -> Option<Self> {
        it.into_iter().reduce(Self::join)
    }
}

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

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

    pub fn status_category() -> impl Strategy<Value = StatusCategory> {
        prop_oneof![
            Just(StatusCategory::Queued),
            Just(StatusCategory::Active),
            Just(StatusCategory::Stalled),
            Just(StatusCategory::Resolved),
            Just(StatusCategory::Cancelled),
            Just(StatusCategory::Unknown),
        ]
    }
}

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

    #[test]
    fn parse_known_tokens() {
        assert_eq!(StatusCategory::parse("queued"), Ok(StatusCategory::Queued));
        assert_eq!(StatusCategory::parse("active"), Ok(StatusCategory::Active));
        assert_eq!(
            StatusCategory::parse("stalled"),
            Ok(StatusCategory::Stalled)
        );
        assert_eq!(
            StatusCategory::parse("resolved"),
            Ok(StatusCategory::Resolved)
        );
        assert_eq!(
            StatusCategory::parse("cancelled"),
            Ok(StatusCategory::Cancelled)
        );
    }

    #[test]
    fn parse_empty_or_garbage_yields_unknown() {
        assert_eq!(StatusCategory::parse(""), Ok(StatusCategory::Unknown));
        assert_eq!(StatusCategory::parse("done"), Ok(StatusCategory::Unknown));
        assert_eq!(StatusCategory::parse("Queued"), Ok(StatusCategory::Unknown));
    }

    #[test]
    fn parse_rejects_legacy_ongoing() {
        let err = StatusCategory::parse("ongoing").unwrap_err();
        let ParseStatusCategoryError::LegacyToken {
            token,
            replacement_hint,
        } = err;
        assert_eq!(token, "ongoing");
        assert_eq!(replacement_hint, "\"active\"");
    }

    #[test]
    fn parse_rejects_legacy_pending() {
        let err = StatusCategory::parse("pending").unwrap_err();
        let ParseStatusCategoryError::LegacyToken {
            token,
            replacement_hint,
        } = err;
        assert_eq!(token, "pending");
        assert!(replacement_hint.contains("queued"));
        assert!(replacement_hint.contains("stalled"));
    }

    #[test]
    fn legacy_error_display_names_the_replacement() {
        let err = StatusCategory::parse("ongoing").unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("\"ongoing\""), "got: {msg}");
        assert!(msg.contains("\"active\""), "got: {msg}");
    }

    #[test]
    fn as_str_canonical_tokens() {
        assert_eq!(StatusCategory::Queued.as_str(), "queued");
        assert_eq!(StatusCategory::Active.as_str(), "active");
        assert_eq!(StatusCategory::Stalled.as_str(), "stalled");
        assert_eq!(StatusCategory::Resolved.as_str(), "resolved");
        assert_eq!(StatusCategory::Cancelled.as_str(), "cancelled");
        assert_eq!(StatusCategory::Unknown.as_str(), "unknown");
    }

    #[test]
    fn is_known() {
        for cat in [
            StatusCategory::Queued,
            StatusCategory::Active,
            StatusCategory::Stalled,
            StatusCategory::Resolved,
            StatusCategory::Cancelled,
        ] {
            assert!(cat.is_known(), "{cat} should be known");
        }
        assert!(!StatusCategory::Unknown.is_known());
    }

    #[test]
    fn is_terminal() {
        assert!(StatusCategory::Resolved.is_terminal());
        assert!(StatusCategory::Cancelled.is_terminal());
        assert!(!StatusCategory::Queued.is_terminal());
        assert!(!StatusCategory::Active.is_terminal());
        assert!(!StatusCategory::Stalled.is_terminal());
        assert!(!StatusCategory::Unknown.is_terminal());
    }

    #[test]
    fn default_is_unknown() {
        assert_eq!(StatusCategory::default(), StatusCategory::Unknown);
    }

    proptest! {
        #[test]
        fn known_categories_round_trip_through_parse(cat in strategy::status_category()) {
            if cat.is_known() {
                prop_assert_eq!(StatusCategory::parse(cat.as_str()), Ok(cat));
            }
        }
    }

    // ── Rollup join ─────────────────────────────────────────────────────

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

    /// Mirrors the operator table on the chain
    /// `Unknown < Cancelled < Resolved < Queued < Stalled < Active`.
    ///
    /// | ⊔ | Q | A | S | R | C |
    /// |---|---|---|---|---|---|
    /// | Q | Q | A | S | Q | Q |
    /// | A | A | A | A | A | A |
    /// | S | S | A | S | S | S |
    /// | R | Q | A | S | R | R |
    /// | C | Q | A | S | R | C |
    #[test]
    fn join_table_matches_ddr() {
        #[rustfmt::skip]
        let table = [
            (Q, Q, Q), (Q, A, A), (Q, S, S), (Q, R, Q), (Q, C, Q),
            (A, Q, A), (A, A, A), (A, S, A), (A, R, A), (A, C, A),
            (S, Q, S), (S, A, A), (S, S, S), (S, R, S), (S, C, S),
            (R, Q, Q), (R, A, A), (R, S, S), (R, R, R), (R, C, R),
            (C, Q, Q), (C, A, A), (C, S, S), (C, R, R), (C, C, C),
        ];
        for (a, b, expected) in table {
            assert_eq!(
                StatusCategory::join(a, b),
                expected,
                "join({a}, {b}) expected {expected}"
            );
        }
    }

    #[test]
    fn join_unknown_is_bottom() {
        for x in [Q, A, S, R, C, StatusCategory::Unknown] {
            assert_eq!(StatusCategory::join(StatusCategory::Unknown, x), x);
            assert_eq!(StatusCategory::join(x, StatusCategory::Unknown), x);
        }
    }

    #[test]
    fn fold_empty_iterator_is_none() {
        assert_eq!(StatusCategory::fold_categories(std::iter::empty()), None);
    }

    #[test]
    fn fold_single_child_is_its_category() {
        assert_eq!(StatusCategory::fold_categories([Q]), Some(Q));
        assert_eq!(StatusCategory::fold_categories([R]), Some(R));
    }

    /// Each example from the DDR's "Worked examples" table.
    #[test]
    fn fold_worked_examples_from_ddr() {
        let cases: &[(&[StatusCategory], StatusCategory)] = &[
            (&[Q, Q, Q, Q], Q),
            (&[S, S, S], S),
            (&[R, R, R, R, R], R),
            (&[C, C], C),
            (&[A, A, A], A),
            (&[Q, Q, S], S),
            (&[Q, Q, Q, A], A),
            (&[Q, S, A], A),
            (&[S, S, R], S),
            (&[Q, R], Q),
            (&[Q, C], Q),
            (&[R, R, R, R, R, R, R, R, R, S], S),
            (&[R, R, R, R, R, R, R, R, R, C], R),
            (&[R, C, C], R),
            (&[C, C, C, C], C),
            (&[Q, A, S, R, C], A),
        ];
        for (children, expected) in cases {
            let got = StatusCategory::fold_categories(children.iter().copied());
            assert_eq!(
                got,
                Some(*expected),
                "fold({children:?}) expected {expected}, got {got:?}"
            );
        }
    }

    proptest! {
        #[test]
        fn join_is_idempotent(a in strategy::status_category()) {
            prop_assert_eq!(StatusCategory::join(a, a), a);
        }

        #[test]
        fn join_is_commutative(
            a in strategy::status_category(),
            b in strategy::status_category(),
        ) {
            prop_assert_eq!(StatusCategory::join(a, b), StatusCategory::join(b, a));
        }

        #[test]
        fn join_is_associative(
            a in strategy::status_category(),
            b in strategy::status_category(),
            c in strategy::status_category(),
        ) {
            let left = StatusCategory::join(StatusCategory::join(a, b), c);
            let right = StatusCategory::join(a, StatusCategory::join(b, c));
            prop_assert_eq!(left, right);
        }

        #[test]
        fn fold_is_order_independent(
            a in strategy::status_category(),
            b in strategy::status_category(),
            c in strategy::status_category(),
        ) {
            let abc = StatusCategory::fold_categories([a, b, c]);
            let cba = StatusCategory::fold_categories([c, b, a]);
            prop_assert_eq!(abc, cba);
        }
    }
}