cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
mod event_action;
mod event_log;
mod event_value;
mod state;

pub use event_action::EventAction;
pub use event_log::EventLog;
pub use event_value::Event;
pub use state::State;

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

    pub fn event_action() -> impl Strategy<Value = EventAction> {
        use super::state::strategy::state;
        prop_oneof![
            state().prop_map(|s| EventAction::Created { state: s }),
            (state(), state()).prop_map(|(from, to)| EventAction::StatusChanged { from, to }),
        ]
    }

    pub fn event() -> impl Strategy<Value = Event> {
        (timestamp(), event_action()).prop_map(|(timestamp, action)| Event { timestamp, action })
    }

    pub fn event_log() -> impl Strategy<Value = EventLog> {
        proptest::collection::vec(event(), 0..5).prop_map(EventLog::from_iter)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::temporal::duration::Duration;
    use crate::domain::model::temporal::iso_date::IsoDate;
    use crate::domain::model::temporal::timestamp::Timestamp;
    use proptest::prelude::*;

    fn st(s: &str) -> State {
        State::new(s).unwrap()
    }

    fn is_terminal(s: &str) -> bool {
        s == "closed"
    }
    fn is_ongoing(s: &str) -> bool {
        s == "in-progress"
    }
    fn is_stalled(s: &str) -> bool {
        s == "blocked" || s == "review"
    }

    fn created(ts: &str, state: &str) -> Event {
        Event {
            timestamp: Timestamp::new(ts).unwrap(),
            action: EventAction::Created { state: st(state) },
        }
    }

    fn changed(ts: &str, from: &str, to: &str) -> Event {
        Event {
            timestamp: Timestamp::new(ts).unwrap(),
            action: EventAction::StatusChanged {
                from: st(from),
                to: st(to),
            },
        }
    }

    #[test]
    fn is_created_and_is_status_changed() {
        let c = EventAction::Created { state: st("open") };
        assert!(c.is_created());
        assert!(!c.is_status_changed());
        let sc = EventAction::StatusChanged {
            from: st("open"),
            to: st("closed"),
        };
        assert!(sc.is_status_changed());
        assert!(!sc.is_created());
    }

    #[test]
    fn as_str_variants() {
        assert_eq!(
            EventAction::Created { state: st("open") }.as_str(),
            "created"
        );
        assert_eq!(
            EventAction::StatusChanged {
                from: st("open"),
                to: st("closed"),
            }
            .as_str(),
            "status_changed"
        );
    }

    #[test]
    fn event_log_push_and_len() {
        let mut log = EventLog::new();
        log.push(created("2026-03-11T00:00:00Z", "open"));
        assert_eq!(log.len(), 1);
    }

    #[test]
    fn event_log_creation_date() {
        let mut log = EventLog::new();
        log.push(created("2026-03-10T08:00:00Z", "open"));
        assert_eq!(
            log.creation_date(&IsoDate::new("2026-01-01").unwrap()),
            IsoDate::new("2026-03-10").unwrap()
        );
    }

    #[test]
    fn event_log_last_activity_date() {
        let mut log = EventLog::new();
        log.push(created("2026-03-05T00:00:00Z", "open"));
        log.push(changed("2026-03-15T12:00:00Z", "open", "closed"));
        assert_eq!(
            log.last_activity_date(&IsoDate::new("2026-01-01").unwrap()),
            IsoDate::new("2026-03-15").unwrap()
        );
    }

    #[test]
    fn latest_state_returns_none_for_empty_log() {
        assert!(EventLog::new().latest_state().is_none());
    }

    #[test]
    fn latest_state_returns_created_status_when_no_transition() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        assert_eq!(log.latest_state().map(|s| s.as_str()), Some("open"));
    }

    #[test]
    fn latest_state_returns_to_of_last_status_changed() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-05T00:00:00Z", "open", "closed"));
        assert_eq!(log.latest_state().map(|s| s.as_str()), Some("closed"));
    }

    // ── lead_time ─────────────────────────────────────────────────────

    #[test]
    fn lead_time_returns_none_for_empty_log() {
        let fallback = IsoDate::new("2026-03-01").unwrap();
        assert!(EventLog::new().lead_time(&fallback, is_terminal).is_none());
    }

    #[test]
    fn lead_time_returns_none_when_not_closed() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        let fallback = IsoDate::new("2026-03-01").unwrap();
        assert!(log.lead_time(&fallback, is_terminal).is_none());
    }

    #[test]
    fn lead_time_returns_days_from_creation_to_close() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
        let fallback = IsoDate::new("2026-03-01").unwrap();
        assert_eq!(
            log.lead_time(&fallback, is_terminal),
            Some(Duration::from_days(8))
        );
    }

    // ── cycle_time ──────────────────────────────────────────────────────

    #[test]
    fn cycle_time_returns_none_when_not_closed() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        let fallback = IsoDate::new("2026-03-01").unwrap();
        assert!(log.cycle_time(&fallback, is_terminal, is_ongoing).is_none());
    }

    #[test]
    fn cycle_time_uses_first_ongoing_to_close() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-05T00:00:00Z", "open", "in-progress"));
        log.push(changed("2026-03-09T00:00:00Z", "in-progress", "closed"));
        let fallback = IsoDate::new("2026-03-01").unwrap();
        assert_eq!(
            log.cycle_time(&fallback, is_terminal, is_ongoing),
            Some(Duration::from_days(4))
        );
    }

    #[test]
    fn cycle_time_falls_back_to_creation_when_no_ongoing() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
        let fallback = IsoDate::new("2026-03-01").unwrap();
        // No ongoing transition → cycle time == lead time
        assert_eq!(
            log.cycle_time(&fallback, is_terminal, is_ongoing),
            Some(Duration::from_days(8))
        );
    }

    // ── flow_efficiency_pct ─────────────────────────────────────────────────

    #[test]
    fn flow_efficiency_pct_returns_none_when_no_active_period() {
        // Created and never transitioned → no measurable active period.
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        assert!(log.flow_efficiency_pct(is_ongoing, is_stalled).is_none());
    }

    #[test]
    fn flow_efficiency_pct_returns_none_when_no_ongoing_transition() {
        // open → closed, never touches an active state → None.
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
        assert!(log.flow_efficiency_pct(is_ongoing, is_stalled).is_none());
    }

    #[test]
    fn flow_efficiency_pct_with_no_stall_is_full() {
        // active=8d, stalled=0 → 100% (queue time is excluded).
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-03T00:00:00Z", "open", "in-progress"));
        log.push(changed("2026-03-11T00:00:00Z", "in-progress", "closed"));
        let pct = log.flow_efficiency_pct(is_ongoing, is_stalled).unwrap();
        assert!((pct - 100.0).abs() < 0.5, "expected ~100%, got {pct:.1}%");
    }

    #[test]
    fn flow_efficiency_pct_is_active_over_active_plus_stalled() {
        // active = 4d (in-progress 03→05, 09→11), stalled = 4d (blocked 05→09)
        // → 4 / (4+4) = 50%. Queue time (creation→first active) is *not* in the denominator.
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-03T00:00:00Z", "open", "in-progress"));
        log.push(changed("2026-03-05T00:00:00Z", "in-progress", "blocked"));
        log.push(changed("2026-03-09T00:00:00Z", "blocked", "in-progress"));
        log.push(changed("2026-03-11T00:00:00Z", "in-progress", "closed"));
        let pct = log.flow_efficiency_pct(is_ongoing, is_stalled).unwrap();
        assert!((pct - 50.0).abs() < 1.0, "expected ~50%, got {pct:.1}%");
    }

    // ── queue_time ─────────────────────────────────────────────────────

    #[test]
    fn queue_time_creation_to_first_active() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-04T00:00:00Z", "open", "in-progress"));
        let fallback = IsoDate::new("2026-03-01").unwrap();
        let q = log.queue_time(&fallback, is_ongoing).unwrap();
        assert_eq!(q, Duration::from_days(3));
    }

    #[test]
    fn queue_time_none_when_no_active_transition() {
        let mut log = EventLog::new();
        log.push(created("2026-03-01T00:00:00Z", "open"));
        log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
        let fallback = IsoDate::new("2026-03-01").unwrap();
        assert!(log.queue_time(&fallback, is_ongoing).is_none());
    }

    // is_terminal is no longer used by flow_efficiency_pct — keep silencing.
    #[test]
    fn _is_terminal_helper_still_callable() {
        assert!(is_terminal("closed"));
    }

    proptest! {
        #[test]
        fn prop_event_clone_equals_original(e in strategy::event()) {
            prop_assert_eq!(e.clone(), e);
        }

        #[test]
        fn prop_event_log_clone_equals_original(log in strategy::event_log()) {
            prop_assert_eq!(log.clone(), log);
        }
    }
}