cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::temporal::duration::Duration;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::model::temporal::timestamp::Timestamp;
use serde::Serialize;

use super::{Event, EventAction, State};

/// An ordered, append-only log of history events for a record.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
#[serde(transparent)]
pub struct EventLog(Vec<Event>);

impl EventLog {
    pub fn new() -> Self {
        Self(Vec::new())
    }

    pub(crate) fn push(&mut self, event: Event) {
        self.0.push(event);
    }

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

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

    pub fn first(&self) -> Option<&Event> {
        self.0.first()
    }

    pub fn last(&self) -> Option<&Event> {
        self.0.last()
    }

    pub fn iter(&self) -> std::slice::Iter<'_, Event> {
        self.0.iter()
    }

    /// Return a new log with `event` appended to the end. Pure transform
    /// aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_appended(&self, event: Event) -> EventLog {
        let mut out = self.clone();
        out.0.push(event);
        out
    }

    /// Return the creation date: timestamp of the first `Created` event,
    /// or `fallback` when the log is empty.
    pub fn creation_date(&self, fallback: &IsoDate) -> IsoDate {
        self.0
            .iter()
            .find(|e| e.action.is_created())
            .map(|e| e.timestamp.to_iso_date())
            .unwrap_or(*fallback)
    }

    /// Return the date of the most recent event, or `fallback`.
    pub fn last_activity_date(&self, fallback: &IsoDate) -> IsoDate {
        self.0
            .iter()
            .map(|e| e.timestamp.to_iso_date())
            .max()
            .unwrap_or(*fallback)
    }

    /// Return the current state projected from the event log.
    ///
    /// Returns the `to` state of the most recent `StatusChanged` event, or
    /// the `state` of the `Created` event if no transition has occurred yet.
    /// Returns `None` only when the log is empty (should not happen for valid records).
    pub fn latest_state(&self) -> Option<&State> {
        self.0
            .iter()
            .rev()
            .map(|e| match &e.action {
                EventAction::StatusChanged { to, .. } => to,
                EventAction::Created { state } => state,
            })
            .next()
    }

    /// Return the date of the last `StatusChanged` event whose `to` state
    /// is classified as terminal by `is_terminal`.
    ///
    /// Returns `None` if no such event exists. The classifier comes from
    /// the consuming universe — issues use `StatusesConfig`, decision
    /// records would use `DrStatus::is_terminal`. Keeping the predicate
    /// out of `EventLog` is what lets this module stay a leaf primitive.
    pub fn close_date(&self, is_terminal: impl Fn(&str) -> bool) -> Option<IsoDate> {
        self.0.iter().rev().find_map(|e| {
            if let EventAction::StatusChanged { to, .. } = &e.action {
                if is_terminal(to.as_str()) {
                    return Some(e.timestamp.to_iso_date());
                }
            }
            None
        })
    }

    /// Return the date of the first `StatusChanged` event whose `to` state
    /// is classified as ongoing by `is_ongoing`, or `fallback` if none.
    pub fn first_ongoing_date(
        &self,
        fallback: &IsoDate,
        is_ongoing: impl Fn(&str) -> bool,
    ) -> IsoDate {
        self.0
            .iter()
            .find(|e| {
                matches!(
                    &e.action,
                    EventAction::StatusChanged { to, .. } if is_ongoing(to.as_str())
                )
            })
            .map(|e| e.timestamp.to_iso_date())
            .unwrap_or(*fallback)
    }

    /// Return the raw timestamp string of the first ongoing transition.
    pub fn first_ongoing_timestamp(&self, is_ongoing: impl Fn(&str) -> bool) -> Option<String> {
        self.0
            .iter()
            .find(|e| {
                matches!(
                    &e.action,
                    EventAction::StatusChanged { to, .. } if is_ongoing(to.as_str())
                )
            })
            .map(|e| e.timestamp.as_str().to_string())
    }

    /// Compute total time (fractional days) spent in ongoing states.
    ///
    /// Returns `None` if there are fewer than 2 events or no ongoing
    /// period can be measured.
    pub fn active_duration(&self, is_ongoing: impl Fn(&str) -> bool) -> Option<Duration> {
        if self.0.len() < 2 {
            return None;
        }

        let mut total = Duration::default();
        let mut ongoing_since: Option<&Timestamp> = None;

        for event in &self.0 {
            let ongoing = match &event.action {
                EventAction::StatusChanged { to, .. } => is_ongoing(to.as_str()),
                _ => continue,
            };

            if ongoing {
                if ongoing_since.is_none() {
                    ongoing_since = Some(&event.timestamp);
                }
            } else if let Some(since) = ongoing_since.take() {
                let d = event.timestamp.duration_since(since);
                if d.is_positive() {
                    total += d;
                }
            }
        }

        if total.is_zero() {
            None
        } else {
            Some(total)
        }
    }

    /// Lead time: creation date to close date.
    pub fn lead_time(
        &self,
        creation_fallback: &IsoDate,
        is_terminal: impl Fn(&str) -> bool,
    ) -> Option<Duration> {
        let created = self.creation_date(creation_fallback);
        let closed = self.close_date(is_terminal)?;
        let d = created.duration_until(&closed);
        if d.is_positive() || d.is_zero() {
            Some(d)
        } else {
            None
        }
    }

    /// Flow efficiency in the Lean sense:
    /// `Active / (Active + Stalled)`, as a percentage.
    ///
    /// Queued time is *not* in the denominator — it is lead time
    /// (creation → first active), reported as its own metric.
    /// Returns `None` if no active period can be measured.
    pub fn flow_efficiency_pct(
        &self,
        is_ongoing: impl Fn(&str) -> bool,
        is_stalled: impl Fn(&str) -> bool,
    ) -> Option<f64> {
        let active = self.active_duration(is_ongoing)?.as_days();
        let stalled = self
            .active_duration(is_stalled)
            .map(|d| d.as_days())
            .unwrap_or(0.0);
        let denom = active + stalled;
        if denom <= 0.0 {
            return None;
        }
        Some((active / denom) * 100.0)
    }

    /// Queue time: creation date to first active transition.
    ///
    /// The `Queued` window before any work began on the issue. Returns
    /// `None` if no active transition exists.
    pub fn queue_time(
        &self,
        creation_fallback: &IsoDate,
        is_ongoing: impl Fn(&str) -> bool,
    ) -> Option<Duration> {
        let created = self.creation_date(creation_fallback);
        let started = self.first_ongoing_date(&created, is_ongoing);
        if started == created {
            return None;
        }
        let d = created.duration_until(&started);
        if d.is_positive() {
            Some(d)
        } else {
            None
        }
    }

    /// Cycle time: first ongoing transition to close date.
    ///
    /// Falls back to creation date when no ongoing transition exists.
    pub fn cycle_time(
        &self,
        creation_fallback: &IsoDate,
        is_terminal: impl Fn(&str) -> bool,
        is_ongoing: impl Fn(&str) -> bool,
    ) -> Option<Duration> {
        let started = self.first_ongoing_date(&self.creation_date(creation_fallback), is_ongoing);
        let closed = self.close_date(is_terminal)?;
        let d = started.duration_until(&closed);
        if d.is_positive() || d.is_zero() {
            Some(d)
        } else {
            None
        }
    }
}

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

impl<'a> IntoIterator for &'a EventLog {
    type Item = &'a Event;
    type IntoIter = std::slice::Iter<'a, Event>;
    fn into_iter(self) -> Self::IntoIter {
        self.0.iter()
    }
}

impl IntoIterator for EventLog {
    type Item = Event;
    type IntoIter = std::vec::IntoIter<Event>;
    fn into_iter(self) -> Self::IntoIter {
        self.0.into_iter()
    }
}

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

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

    /// Generate an `EventLog` of 0..6 arbitrary events. Events are not
    /// sorted; tests that need chronological order should sort.
    pub fn event_log() -> impl Strategy<Value = EventLog> {
        proptest::collection::vec(event(), 0..6).prop_map(|events| events.into_iter().collect())
    }
}

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

    proptest! {
        #[test]
        fn len_matches_iter_count(log in strategy::event_log()) {
            prop_assert_eq!(log.len(), log.iter().count());
        }

        #[test]
        fn first_is_some_iff_log_non_empty(log in strategy::event_log()) {
            prop_assert_eq!(log.first().is_some(), !log.is_empty());
        }
    }
}