use crate::domain::model::issue::{Issue, IssueFilter, IssueRelationship};
use crate::domain::model::tag_descriptor::{TagDescriptor, TagDescriptors};
use crate::domain::usecases::issue::filter::filter_issues;
use crate::domain::usecases::issue::rollup_status::{
compute_status_rollup_via_map, index_issues_by_id,
};
use crate::domain::usecases::issue::rollup_tags::compute_tag_rollups;
use crate::domain::usecases::issue::tree_view::TreeRollup;
use crate::domain::usecases::issue::IssueRepository;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedIssue {
pub issue: Issue,
pub rollup: Option<TreeRollup>,
}
pub fn list_issues(
repo: &dyn IssueRepository,
filter: &IssueFilter<'_>,
sort_by: Option<&TagDescriptor>,
descriptors: &TagDescriptors,
with_rollups: bool,
) -> anyhow::Result<Vec<ListedIssue>> {
let mut all = repo.list()?.into_vec();
all.sort_by(|a, b| a.id.cmp(&b.id));
let mut filtered: Vec<Issue> = filter_issues(&all, filter).into_iter().cloned().collect();
if let Some(descriptor) = sort_by {
sort_issues_by_descriptor(&mut filtered, descriptor);
}
if !with_rollups {
return Ok(filtered
.into_iter()
.map(|i| ListedIssue {
issue: i,
rollup: None,
})
.collect());
}
let by_id = index_issues_by_id(&all);
let listed = filtered
.into_iter()
.map(|issue| {
let status = compute_status_rollup_via_map(&issue, &by_id);
let children: Vec<&Issue> = issue
.links
.iter()
.filter(|l| l.relationship == IssueRelationship::ParentOf)
.filter_map(|l| by_id.get(&l.target).copied())
.collect();
let tags = compute_tag_rollups(&children, descriptors);
let rollup = TreeRollup { status, tags };
let rollup = if rollup.is_empty() {
None
} else {
Some(rollup)
};
ListedIssue { issue, rollup }
})
.collect();
Ok(listed)
}
pub fn sort_issues_by_descriptor(issues: &mut [Issue], descriptor: &TagDescriptor) {
issues.sort_by(|a, b| {
let ra = rank_for(a, descriptor);
let rb = rank_for(b, descriptor);
ra.cmp(&rb).then_with(|| a.id.cmp(&b.id))
});
}
fn rank_for(issue: &Issue, descriptor: &TagDescriptor) -> usize {
issue
.tags
.iter()
.filter_map(|t| t.as_kv())
.find(|(k, _)| *k == descriptor.key)
.and_then(|(_, v)| descriptor.rank(v))
.unwrap_or(usize::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::issue::test_fixtures::{feature, ir};
use crate::domain::model::status::StatusesConfig;
use crate::domain::usecases::issue::test_support::FakeIssueRepository;
use crate::domain::usecases::issue::tests::enrich_issue;
fn enriched(id: u64, title: &str) -> Issue {
let mut i = feature(title).with_id(&ir(id).to_string()).build(ir(id));
enrich_issue(&mut i, &StatusesConfig::default_issue());
i
}
fn no_filter() -> IssueFilter<'static> {
IssueFilter {
status: None,
active: false,
tags: &[],
}
}
#[test]
fn empty_corpus_yields_an_empty_list() {
let repo = FakeIssueRepository::new();
let listed =
list_issues(&repo, &no_filter(), None, &TagDescriptors::default(), false).unwrap();
assert!(listed.is_empty());
}
#[test]
fn no_rollup_flag_returns_rollup_none_for_every_entry() {
let repo = FakeIssueRepository::with_issues(vec![enriched(1, "A"), enriched(2, "B")]);
let listed =
list_issues(&repo, &no_filter(), None, &TagDescriptors::default(), false).unwrap();
assert_eq!(listed.len(), 2);
assert!(listed.iter().all(|l| l.rollup.is_none()));
}
#[test]
fn parent_carries_a_rollup_when_flag_is_set() {
let mut parent = feature("Parent")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "parent-of")
.build(ir(1));
enrich_issue(&mut parent, &StatusesConfig::default_issue());
let mut child = feature("Child")
.with_id("ISSUE-0002")
.with_link("ISSUE-0001", "child-of")
.build(ir(2));
enrich_issue(&mut child, &StatusesConfig::default_issue());
let repo = FakeIssueRepository::with_issues(vec![parent, child]);
let listed =
list_issues(&repo, &no_filter(), None, &TagDescriptors::default(), true).unwrap();
let parent_entry = listed.iter().find(|l| l.issue.id == ir(1)).unwrap();
let rollup = parent_entry
.rollup
.as_ref()
.expect("parent should carry a rollup");
assert!(rollup.status.is_some());
}
}
#[cfg(test)]
mod sort_tests {
use super::*;
use crate::domain::model::tag_descriptor::{Cardinality, TagDescriptor};
use crate::domain::usecases::issue::tests::{feature, ir};
fn priority_descriptor() -> TagDescriptor {
TagDescriptor {
key: "priority".into(),
levels: vec!["high".into(), "medium".into(), "low".into()],
cardinality: Cardinality::AtMostOne,
ordered: true,
weights: None,
aggregate: None,
applies_to: vec!["issues".into()],
}
}
#[test]
fn sorts_by_descriptor_rank_with_unknown_at_end() {
let mut issues = vec![
feature("low one").tags("priority:low").build(ir(1)),
feature("high one").tags("priority:high").build(ir(2)),
feature("no tag").build(ir(3)),
feature("medium").tags("priority:medium").build(ir(4)),
];
sort_issues_by_descriptor(&mut issues, &priority_descriptor());
let titles: Vec<&str> = issues.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["high one", "medium", "low one", "no tag"]);
}
#[test]
fn ties_broken_by_id_ascending() {
let mut issues = vec![
feature("c").tags("priority:high").build(ir(3)),
feature("a").tags("priority:high").build(ir(1)),
feature("b").tags("priority:high").build(ir(2)),
];
sort_issues_by_descriptor(&mut issues, &priority_descriptor());
let ids: Vec<&str> = issues.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["ISSUE-0001", "ISSUE-0002", "ISSUE-0003"]);
}
#[test]
fn unknown_value_in_closed_vocabulary_is_treated_as_missing() {
let mut issues = vec![
feature("low").tags("priority:low").build(ir(1)),
feature("bogus").tags("priority:bogus").build(ir(2)),
];
sort_issues_by_descriptor(&mut issues, &priority_descriptor());
let titles: Vec<&str> = issues.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["low", "bogus"]);
}
}