cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::issue::{Issue, IssueView};
use crate::domain::model::status::{Status, StatusesConfig};
use crate::domain::model::temporal::iso_date::IsoDate;

use super::helpers::is_ongoing;
use super::{FlowLoad, WipItem};

pub(super) fn compute_flow_load(
    filtered: &IssueView<'_>,
    statuses: &StatusesConfig,
    today: &IsoDate,
    cycle_time_p85: Option<f64>,
    stale_after: u32,
    detailed: bool,
) -> FlowLoad {
    let wip_issues: Vec<(&Issue, u32)> = filtered
        .iter()
        .filter(|i| !i.status.terminal)
        .filter_map(|i| {
            let ts_str = i.events.first_ongoing_timestamp(is_ongoing(statuses))?;
            let onset = IsoDate::new(&ts_str[..10]).ok()?;
            let age = onset.duration_until(today);
            if age.is_positive() || age.is_zero() {
                Some((*i, age.as_whole_days() as u32))
            } else {
                None
            }
        })
        .collect();

    let wip_total = wip_issues.len() as u32;

    let (by_status_wip, items) = if detailed {
        let mut sm: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
        for (issue, _) in &wip_issues {
            *sm.entry(issue.status.as_str().to_string()).or_insert(0) += 1;
        }
        let mut by_st: Vec<(Status, u32)> = sm
            .into_iter()
            .map(|(s, c)| (Status::unresolved(s), c))
            .collect();
        by_st.sort_by_key(|b| std::cmp::Reverse(b.1));

        let mut items_list: Vec<WipItem> = wip_issues
            .iter()
            .map(|(issue, age_days)| {
                let at_risk = cycle_time_p85.is_some_and(|p85| *age_days as f64 > p85);
                let last_activity_days = issue
                    .events
                    .last_activity_date(&issue.date)
                    .duration_until(today)
                    .as_whole_days()
                    .max(0) as u32;
                let stale = last_activity_days > stale_after;
                WipItem {
                    id: issue.id.clone(),
                    status: issue.status.clone(),
                    age_days: *age_days,
                    at_risk,
                    last_activity_days,
                    stale,
                }
            })
            .collect();
        items_list.sort_by_key(|b| std::cmp::Reverse(b.age_days));
        (by_st, items_list)
    } else {
        (vec![], vec![])
    };

    FlowLoad {
        total: wip_total,
        by_status: by_status_wip,
        items,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::status::StatusesConfig;
    use crate::domain::model::temporal::iso_date::IsoDate;
    use crate::domain::usecases::issue::tests::{enrich_issue, feature, ir, IssueFixture};

    // ── Tests ─────────────────────────────────────────────────────────────────

    #[test]
    fn only_issues_with_ongoing_transition_count_as_wip() {
        scenario("2026-03-12")
            .given(
                feature("Started")
                    .status("in-progress")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-05T00:00:00Z",
                        "status_changed",
                        Some("to-do"),
                        Some("in-progress"),
                    ),
            )
            .given(feature("Backlog").status("to-do"))
            .when_flow_load(false)
            .then_total(1);
    }

    #[test]
    fn detailed_populates_items_with_age_and_staleness() {
        scenario("2026-03-12")
            .given(
                feature("WIP")
                    .status("in-progress")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-05T00:00:00Z",
                        "status_changed",
                        Some("to-do"),
                        Some("in-progress"),
                    )
                    .with_timestamped_event("2026-03-09T00:00:00Z", "content_updated", None, None),
            )
            .when_flow_load(true)
            .then_item_age_days(0, 7) // 2026-03-05 → 2026-03-12
            .then_item_last_activity_days(0, 3) // 2026-03-09 → 2026-03-12
            .then_item_not_stale(0); // 3 is not > 3
    }

    #[test]
    fn item_is_stale_when_last_activity_exceeds_threshold() {
        scenario("2026-03-12")
            .given(
                feature("Stale")
                    .status("in-progress")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-05T00:00:00Z",
                        "status_changed",
                        Some("to-do"),
                        Some("in-progress"),
                    )
                    .with_timestamped_event("2026-03-08T00:00:00Z", "content_updated", None, None),
            )
            .when_flow_load(true)
            .then_item_last_activity_days(0, 4)
            .then_item_stale(0);
    }

    #[test]
    fn non_detailed_returns_empty_items_and_by_status() {
        scenario("2026-03-12")
            .given(feature("WIP").status("in-progress").with_timestamped_event(
                "2026-03-05T00:00:00Z",
                "status_changed",
                Some("to-do"),
                Some("in-progress"),
            ))
            .when_flow_load(false)
            .then_total(1)
            .then_items_empty()
            .then_by_status_empty();
    }

    // ── Scenario DSL ──────────────────────────────────────────────────────────

    struct Scenario {
        issues: Vec<Issue>,
        today: IsoDate,
    }

    fn scenario(today: &str) -> Scenario {
        Scenario {
            issues: vec![],
            today: IsoDate::new(today).unwrap(),
        }
    }

    impl Scenario {
        fn given(mut self, fixture: IssueFixture) -> Self {
            let id = self.issues.len() as u64 + 1;
            let mut issue = fixture.build(ir(id));
            enrich_issue(&mut issue, &StatusesConfig::default_issue());
            self.issues.push(issue);
            self
        }

        fn when_flow_load(self, detailed: bool) -> FlowLoadOutcome {
            let refs = IssueView::from_slice(&self.issues);
            let statuses = StatusesConfig::default_issue();
            let result = compute_flow_load(&refs, &statuses, &self.today, None, 3, detailed);
            FlowLoadOutcome { result }
        }
    }

    struct FlowLoadOutcome {
        result: FlowLoad,
    }

    impl FlowLoadOutcome {
        fn then_total(self, expected: u32) -> Self {
            assert_eq!(self.result.total, expected, "total mismatch");
            self
        }

        fn then_items_empty(self) -> Self {
            assert!(self.result.items.is_empty(), "expected items to be empty");
            self
        }

        fn then_by_status_empty(self) -> Self {
            assert!(
                self.result.by_status.is_empty(),
                "expected by_status to be empty"
            );
            self
        }

        fn then_item_age_days(self, idx: usize, expected: u32) -> Self {
            let item = &self.result.items[idx];
            assert_eq!(item.age_days, expected, "item[{idx}].age_days mismatch");
            self
        }

        fn then_item_last_activity_days(self, idx: usize, expected: u32) -> Self {
            let item = &self.result.items[idx];
            assert_eq!(
                item.last_activity_days, expected,
                "item[{idx}].last_activity_days mismatch"
            );
            self
        }

        fn then_item_stale(self, idx: usize) -> Self {
            assert!(self.result.items[idx].stale, "item[{idx}] should be stale");
            self
        }

        fn then_item_not_stale(self, idx: usize) -> Self {
            assert!(
                !self.result.items[idx].stale,
                "item[{idx}] should not be stale"
            );
            self
        }
    }
}