use pinto::backlog::{BacklogItem, ItemId};
use pinto::i18n::{Localizer, Message};
use pinto::service::Board;
use std::collections::{HashMap, HashSet};
fn board_items(board: &Board) -> impl Iterator<Item = &BacklogItem> {
board
.columns
.iter()
.flat_map(|c| c.items.iter())
.chain(board.orphaned.iter())
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct DepSummary {
pub(crate) depends_on: Vec<ItemId>,
pub(crate) dependents: Vec<ItemId>,
pub(crate) blocked: bool,
}
impl DepSummary {
pub(crate) fn is_empty(&self) -> bool {
self.depends_on.is_empty() && self.dependents.is_empty()
}
}
pub(crate) struct DependencyIndex {
dependents: HashMap<ItemId, Vec<ItemId>>,
completed: HashSet<ItemId>,
}
impl DependencyIndex {
pub(crate) fn from_board(board: &Board) -> Self {
let mut dependents: HashMap<ItemId, Vec<ItemId>> = HashMap::new();
let mut completed = HashSet::new();
for it in board_items(board) {
if it.done_at.is_some() {
completed.insert(it.id.clone());
}
for dep in &it.depends_on {
dependents
.entry(dep.clone())
.or_default()
.push(it.id.clone());
}
}
Self {
dependents,
completed,
}
}
pub(crate) fn summary(&self, item: &BacklogItem) -> DepSummary {
DepSummary {
depends_on: item.depends_on.clone(),
dependents: self.dependents.get(&item.id).cloned().unwrap_or_default(),
blocked: item.depends_on.iter().any(|d| !self.completed.contains(d)),
}
}
pub(crate) fn any_dependencies(&self, board: &Board) -> bool {
board_items(board).any(|it| !self.summary(it).is_empty())
}
}
pub(crate) fn dependency_legend(localizer: &Localizer) -> String {
localizer.text(Message::KanbanDependencyLegend)
}
pub(crate) const DEP_ID_LIMIT: usize = 3;
pub(crate) fn format_ids(ids: &[ItemId], max: usize) -> String {
if ids.len() <= max {
ids.iter()
.map(ItemId::to_string)
.collect::<Vec<_>>()
.join(" ")
} else {
let shown = ids[..max]
.iter()
.map(ItemId::to_string)
.collect::<Vec<_>>()
.join(" ");
format!("{shown} +{}", ids.len() - max)
}
}
#[cfg(test)]
mod tests {
use super::*;
use pinto::backlog::{ItemId, Status};
use pinto::rank::Rank;
use pinto::service::BoardColumn;
fn item(id: &str, title: &str) -> BacklogItem {
BacklogItem::new(
id.parse::<ItemId>().unwrap(),
title.to_string(),
Status::new("todo"),
Rank::between(None, None).expect("open bounds produce a rank"),
chrono::Utc::now(),
)
.unwrap()
}
fn item_with_deps(id: &str, title: &str, deps: &[&str]) -> BacklogItem {
let mut it = item(id, title);
it.depends_on = deps.iter().map(|d| d.parse::<ItemId>().unwrap()).collect();
it
}
fn completed_item(id: &str, title: &str) -> BacklogItem {
let mut it = item(id, title);
it.done_at = Some(chrono::Utc::now());
it
}
fn board_of(columns: &[(&str, Vec<BacklogItem>)]) -> Board {
Board {
columns: columns
.iter()
.map(|(name, items)| BoardColumn {
status: Status::new(*name),
items: items.clone(),
})
.collect(),
orphaned: Vec::new(),
}
}
fn ids(v: &[ItemId]) -> Vec<String> {
v.iter().map(ItemId::to_string).collect()
}
#[test]
fn summary_lists_depends_on_and_dependents() {
let board = board_of(&[(
"todo",
vec![item("T-1", "a"), item_with_deps("T-2", "b", &["T-1"])],
)]);
let idx = DependencyIndex::from_board(&board);
let items = &board.columns[0].items;
let s1 = idx.summary(&items[0]); assert_eq!(ids(&s1.dependents), vec!["T-2"], "T-2 depends on T-1");
assert!(s1.depends_on.is_empty());
let s2 = idx.summary(&items[1]); assert_eq!(ids(&s2.depends_on), vec!["T-1"], "T-2 depends on T-1");
assert!(s2.dependents.is_empty());
}
#[test]
fn blocked_when_a_dependency_is_incomplete() {
let board = board_of(&[(
"todo",
vec![item("T-1", "a"), item_with_deps("T-2", "b", &["T-1"])],
)]);
let idx = DependencyIndex::from_board(&board);
assert!(
idx.summary(&board.columns[0].items[1]).blocked,
"incomplete T-1 blocks T-2"
);
}
#[test]
fn not_blocked_when_all_dependencies_complete() {
let board = board_of(&[
("done", vec![completed_item("T-1", "a")]),
("todo", vec![item_with_deps("T-2", "b", &["T-1"])]),
]);
let idx = DependencyIndex::from_board(&board);
assert!(
!idx.summary(&board.columns[1].items[0]).blocked,
"completed dependencies do not block the item"
);
}
#[test]
fn no_dependencies_yields_empty_summary() {
let board = board_of(&[("todo", vec![item("T-1", "a")])]);
let idx = DependencyIndex::from_board(&board);
let s = idx.summary(&board.columns[0].items[0]);
assert!(s.depends_on.is_empty() && s.dependents.is_empty() && !s.blocked);
assert!(s.is_empty());
}
#[test]
fn dependents_are_collected_across_the_whole_board() {
let board = board_of(&[
(
"todo",
vec![item("T-1", "a"), item_with_deps("T-2", "b", &["T-1"])],
),
("done", vec![item_with_deps("T-3", "c", &["T-1"])]),
]);
let idx = DependencyIndex::from_board(&board);
let s = idx.summary(&board.columns[0].items[0]);
assert_eq!(ids(&s.dependents), vec!["T-2", "T-3"]);
}
#[test]
fn dependency_index_covers_orphaned_items_too() {
let board = Board {
columns: Vec::new(),
orphaned: vec![item("T-1", "a"), item_with_deps("T-2", "b", &["T-1"])],
};
let idx = DependencyIndex::from_board(&board);
let s1 = idx.summary(&board.orphaned[0]);
assert_eq!(ids(&s1.dependents), vec!["T-2"]);
let s2 = idx.summary(&board.orphaned[1]);
assert!(s2.blocked, "orphaned dependencies still block the item");
}
#[test]
fn any_dependencies_is_data_driven_not_string_driven() {
let without = board_of(&[("todo", vec![item("T-1", "記号 ⊸ を含むが依存なし")])]);
let idx = DependencyIndex::from_board(&without);
assert!(!idx.any_dependencies(&without), "no deps → false");
let with = board_of(&[(
"todo",
vec![item("T-1", "a"), item_with_deps("T-2", "b", &["T-1"])],
)]);
let idx = DependencyIndex::from_board(&with);
assert!(idx.any_dependencies(&with), "deps present → true");
}
#[test]
fn legend_mentions_all_three_markers() {
use pinto::i18n::localizer_from;
let legend = dependency_legend(&localizer_from(Some("fr_FR.UTF-8"), None));
assert!(legend.contains("⊸ depends on"), "{legend}");
assert!(legend.contains("⊷ depended on by"), "{legend}");
assert!(legend.contains("⊸! unresolved dependency"), "{legend}");
}
#[test]
fn format_ids_truncates_beyond_max() {
let all: Vec<ItemId> = ["T-1", "T-2", "T-3", "T-4", "T-5"]
.iter()
.map(|s| s.parse().unwrap())
.collect();
assert_eq!(format_ids(&all, 3), "T-1 T-2 T-3 +2");
assert_eq!(format_ids(&all[..2], 3), "T-1 T-2");
assert_eq!(format_ids(&[], 3), "");
}
}