cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu backlog` — answers "what's there to do?"
//!
//! Replaces the hand-maintained `backlog.md` file. Two sections:
//!
//! 1. Decision records waiting on a decision — every record whose status is
//!    in the `Pending` category (e.g. `proposed`).
//! 2. Issues that are not yet `Resolved`, grouped by priority. The bucket
//!    order matches `[tags.priority].levels` (highest first); issues
//!    without a `priority:*` tag go in a trailing "no priority" bucket.

use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::issue::Issue;
use crate::domain::model::status::StatusCategory;

/// Pure snapshot — no I/O.
#[derive(Debug)]
pub struct BacklogReport {
    pub dr_sections: Vec<DrSection>,
    pub priority_buckets: Vec<PriorityBucket>,
    pub total_drs: usize,
    pub total_issues: usize,
}

#[derive(Debug)]
pub struct DrSection {
    pub kind: String,
    pub records: Vec<DecisionRecord>,
}

#[derive(Debug)]
pub struct PriorityBucket {
    /// `None` represents the "no priority set" bucket, always last.
    pub priority_label: Option<String>,
    pub issues: Vec<Issue>,
}

/// Read the `priority:<value>` tag from an issue, if any.
fn priority_value(issue: &Issue) -> Option<String> {
    for tag in issue.tags.iter() {
        if let Some(rest) = tag.as_str().strip_prefix("priority:") {
            if !rest.is_empty() {
                return Some(rest.to_string());
            }
        }
    }
    None
}

/// Compute the backlog from pre-loaded data.
pub fn build_backlog(
    issues: Vec<Issue>,
    drs_by_kind: Vec<(String, Vec<DecisionRecord>)>,
    priority_levels: &[String],
) -> BacklogReport {
    let mut dr_sections = Vec::new();
    let mut total_drs = 0;
    for (kind, records) in drs_by_kind {
        // Per DDR-018QWJVHRH35B: a DR pending a decision is exactly one
        // in `proposed`. The category projection was a proxy that only
        // worked by coincidence on the legacy configurable workflow.
        let mut active: Vec<DecisionRecord> = records
            .into_iter()
            .filter(|r| r.status.as_str() == "proposed")
            .collect();
        active.sort_by(|a, b| a.id.cmp(&b.id));
        total_drs += active.len();
        dr_sections.push(DrSection {
            kind,
            records: active,
        });
    }

    let mut open_issues: Vec<Issue> = issues
        .into_iter()
        .filter(|i| i.status.category != StatusCategory::Resolved)
        .collect();
    open_issues.sort_by(|a, b| a.id.cmp(&b.id));
    let total_issues = open_issues.len();

    let mut priority_buckets: Vec<PriorityBucket> = priority_levels
        .iter()
        .map(|label| PriorityBucket {
            priority_label: Some(label.clone()),
            issues: Vec::new(),
        })
        .collect();
    let mut unset = PriorityBucket {
        priority_label: None,
        issues: Vec::new(),
    };

    for issue in open_issues {
        match priority_value(&issue) {
            Some(p) => {
                if let Some(bucket) = priority_buckets
                    .iter_mut()
                    .find(|b| b.priority_label.as_deref() == Some(p.as_str()))
                {
                    bucket.issues.push(issue);
                } else {
                    priority_buckets.push(PriorityBucket {
                        priority_label: Some(p),
                        issues: vec![issue],
                    });
                }
            }
            None => unset.issues.push(issue),
        }
    }

    priority_buckets.retain(|b| !b.issues.is_empty());
    if !unset.issues.is_empty() {
        priority_buckets.push(unset);
    }

    BacklogReport {
        dr_sections,
        priority_buckets,
        total_drs,
        total_issues,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::usecases::decision_record::tests::make_record;
    use crate::domain::usecases::issue::tests::{feature, ir};

    fn proposed() -> crate::domain::model::decision_record::DrStatus {
        crate::domain::model::decision_record::DrStatus::Proposed
    }

    fn accepted() -> crate::domain::model::decision_record::DrStatus {
        crate::domain::model::decision_record::DrStatus::Accepted
    }

    fn enrich_issue_status(
        mut issue: Issue,
        cfg: &crate::domain::model::status::StatusesConfig,
    ) -> Issue {
        if let Ok(s) = cfg.resolve(issue.status.as_str()) {
            issue.status = s;
        }
        issue
    }

    #[test]
    fn empty_input_yields_empty_report() {
        let report = build_backlog(vec![], vec![], &[]);
        assert_eq!(report.total_drs, 0);
        assert_eq!(report.total_issues, 0);
        assert!(report.priority_buckets.is_empty());
        assert!(report.dr_sections.is_empty());
    }

    #[test]
    fn pending_dr_appears_in_section_accepted_does_not() {
        let r1 = make_record(1, "Choose database", proposed());
        let r2 = make_record(2, "Use Rust", accepted());
        let report = build_backlog(vec![], vec![("adr".to_string(), vec![r1, r2])], &[]);
        assert_eq!(report.total_drs, 1);
        assert_eq!(report.dr_sections.len(), 1);
        assert_eq!(report.dr_sections[0].records.len(), 1);
    }

    #[test]
    fn issues_are_grouped_by_priority_in_configured_order() {
        let i1 = feature("Important").priority("high").build(ir(1));
        let i2 = feature("Routine").priority("low").build(ir(2));
        let i3 = feature("Medium one").priority("medium").build(ir(3));
        let levels = vec!["high".to_string(), "medium".to_string(), "low".to_string()];
        let report = build_backlog(vec![i1, i2, i3], vec![], &levels);
        assert_eq!(report.total_issues, 3);
        assert_eq!(report.priority_buckets.len(), 3);
        assert_eq!(
            report.priority_buckets[0].priority_label.as_deref(),
            Some("high")
        );
        assert_eq!(
            report.priority_buckets[1].priority_label.as_deref(),
            Some("medium")
        );
        assert_eq!(
            report.priority_buckets[2].priority_label.as_deref(),
            Some("low")
        );
    }

    #[test]
    fn issues_without_priority_go_in_a_trailing_bucket() {
        let i1 = feature("Tagged").priority("high").build(ir(1));
        let i2 = feature("Untagged").build(ir(2));
        let levels = vec!["high".to_string()];
        let report = build_backlog(vec![i1, i2], vec![], &levels);
        assert_eq!(report.priority_buckets.len(), 2);
        assert_eq!(
            report.priority_buckets[0].priority_label.as_deref(),
            Some("high")
        );
        assert!(report.priority_buckets[1].priority_label.is_none());
    }

    #[test]
    fn closed_issues_are_filtered_out() {
        let cfg = crate::domain::model::status::StatusesConfig::default_issue();
        let open = enrich_issue_status(feature("Active").status("open").build(ir(1)), &cfg);
        let closed = enrich_issue_status(feature("Done").status("closed").build(ir(2)), &cfg);
        assert_eq!(open.status.category, StatusCategory::Queued);
        assert_eq!(closed.status.category, StatusCategory::Resolved);

        let report = build_backlog(vec![open, closed], vec![], &[]);
        assert_eq!(report.total_issues, 1);
        assert_eq!(report.priority_buckets.len(), 1);
    }

    #[test]
    fn unconfigured_priority_value_gets_its_own_bucket() {
        let i1 = feature("Tagged").priority("critical").build(ir(1));
        let levels = vec!["high".to_string(), "low".to_string()];
        let report = build_backlog(vec![i1], vec![], &levels);
        assert_eq!(report.priority_buckets.len(), 1);
        assert_eq!(
            report.priority_buckets[0].priority_label.as_deref(),
            Some("critical")
        );
    }
}