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, 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>,
}

/// List issues from `repo`, applying `filter` (status + active + tag
/// patterns) and an optional descriptor-based sort. When `with_rollups`
/// is true, each entry is decorated with the parent's status + tag
/// rollups computed over the full corpus.
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)
}

/// Sort `issues` in place by the rank of their `<descriptor.key>:<value>` tag
/// inside `descriptor.levels`. Issues missing the tag, or with a value not in
/// the configured levels, are pushed to the end. Tie-breaks by id (ascending)
/// so the order is stable.
///
/// The caller is responsible for checking `descriptor.ordered` before calling
/// — the sort itself does not enforce that.
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"]);
    }
}