use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::issue::Issue;
use crate::domain::model::status::StatusCategory;
#[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 {
pub priority_label: Option<String>,
pub issues: Vec<Issue>,
}
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
}
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 {
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")
);
}
}