use std::collections::{BTreeMap, BTreeSet};
use crate::error::{TixError, render_diagnostics};
use crate::id::{AliasIndex, IdentityNode, IssueRef};
use crate::planning::Progress;
use crate::relationships::RelationshipGraph;
pub const PARENT_KEY: &str = "parent";
pub trait HierarchyNode {
fn number(&self) -> u32;
fn title(&self) -> &str;
fn status(&self) -> &str;
fn canonical_id(&self) -> Option<&str> {
None
}
fn filename(&self) -> Option<&str> {
None
}
fn parent_ref(&self) -> Option<&IssueRef> {
None
}
fn parent_number(&self) -> Option<u32>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParentReference {
pub id: IssueRef,
pub number: u32,
pub display: String,
}
impl ParentReference {
pub fn new<N: HierarchyNode>(issue: &N) -> Self {
Self {
id: hierarchy_ref(issue),
number: issue.number(),
display: display_label(issue.number(), issue.title()),
}
}
}
fn hierarchy_ref<N: HierarchyNode>(issue: &N) -> IssueRef {
issue
.canonical_id()
.and_then(IssueRef::id)
.map(|reference| reference.with_number(issue.number()))
.unwrap_or_else(|| IssueRef::Number(issue.number()))
}
pub fn display_label(number: u32, title: &str) -> String {
let title = title.trim();
if title.is_empty() {
format!("#{number}")
} else {
format!("#{number} {title}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildRef {
pub id: IssueRef,
pub number: u32,
pub display: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildMembership {
pub parent: u32,
pub children: Vec<ChildRef>,
pub child_count: usize,
}
impl ChildMembership {
fn empty(parent: u32) -> Self {
Self {
parent,
children: Vec::new(),
child_count: 0,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HierarchyIndex {
parent_by_child: BTreeMap<IssueRef, IssueRef>,
children_by_parent: BTreeMap<IssueRef, Vec<IssueRef>>,
numbers: BTreeMap<IssueRef, u32>,
refs_by_number: BTreeMap<u32, Vec<IssueRef>>,
labels: BTreeMap<IssueRef, String>,
statuses: BTreeMap<IssueRef, String>,
filenames: BTreeMap<IssueRef, String>,
aliases: AliasIndex,
alias_diagnostics: Vec<HierarchyDiagnostic>,
}
struct AliasNode<'a, N>(&'a N);
impl<N: HierarchyNode> IdentityNode for AliasNode<'_, N> {
fn canonical_id(&self) -> Option<&str> {
self.0.canonical_id()
}
fn issue_number(&self) -> u32 {
self.0.number()
}
fn issue_filename(&self) -> &str {
self.0.filename().unwrap_or("")
}
fn issue_title(&self) -> &str {
self.0.title()
}
}
impl HierarchyIndex {
pub fn build<N: HierarchyNode>(issues: &[N]) -> Self {
let mut index = Self::default();
for issue in issues {
let number = issue.number();
let identity = hierarchy_ref(issue);
index.numbers.insert(identity, number);
index
.refs_by_number
.entry(number)
.or_default()
.push(identity);
index
.labels
.insert(identity, display_label(number, issue.title()));
index
.filenames
.insert(identity, issue.filename().unwrap_or("").to_string());
index.statuses.insert(identity, issue.status().to_string());
}
let alias_nodes: Vec<AliasNode<'_, N>> = issues.iter().map(AliasNode).collect();
index.aliases = AliasIndex::build(&alias_nodes);
for refs in index.refs_by_number.values_mut() {
refs.sort();
refs.dedup();
}
for issue in issues {
let child_identity = hierarchy_ref(issue);
let parent = issue
.parent_ref()
.copied()
.or_else(|| issue.parent_number().map(IssueRef::Number));
if let Some(parent) = parent {
let parent = match index.aliases.resolve(&parent) {
Ok(resolved) => resolved,
Err(error) => {
let kind = match error {
crate::id::AliasError::Ambiguous { .. } => {
HierarchyDiagnosticKind::AmbiguousTarget
}
crate::id::AliasError::DuplicateId { .. } => {
HierarchyDiagnosticKind::DuplicateIdentity
}
crate::id::AliasError::Missing(_) => {
HierarchyDiagnosticKind::UnresolvedParent
}
};
if kind != HierarchyDiagnosticKind::UnresolvedParent {
index.alias_diagnostics.push(
HierarchyDiagnostic::new(
kind,
issue.number(),
format!(
"{PARENT_KEY}: {} cannot be resolved: {}",
parent.human(),
alias_error_text(&error)
),
)
.with_source_file(issue.filename().unwrap_or("")),
);
}
parent
}
};
index.parent_by_child.insert(child_identity, parent);
index
.children_by_parent
.entry(parent)
.or_default()
.push(child_identity);
}
}
for children in index.children_by_parent.values_mut() {
children.sort_by_key(|child| index.numbers.get(child).copied().unwrap_or_default());
}
index
}
fn identity_for_number(&self, issue: u32) -> Option<IssueRef> {
match self.refs_by_number.get(&issue)?.as_slice() {
[one] => Some(*one),
_ => None,
}
}
fn number_for_identity(&self, identity: &IssueRef) -> Option<u32> {
self.numbers.get(identity).copied()
}
pub fn alias_diagnostics(&self) -> &[HierarchyDiagnostic] {
&self.alias_diagnostics
}
pub fn parent_ref_of(&self, issue: u32) -> Option<&IssueRef> {
self.identity_for_number(issue)
.and_then(|identity| self.parent_by_child.get(&identity))
.or_else(|| self.parent_by_child.get(&IssueRef::Number(issue)))
}
pub fn parent_ref_for(&self, issue: &IssueRef) -> Option<&IssueRef> {
self.parent_by_child.get(issue)
}
pub fn identity_ref_of(&self, issue: u32) -> Option<IssueRef> {
self.identity_for_number(issue)
}
pub fn children_of(&self, issue: u32) -> ChildMembership {
let identity = self
.identity_for_number(issue)
.unwrap_or(IssueRef::Number(issue));
let Some(children) = self.children_by_parent.get(&identity) else {
return ChildMembership::empty(issue);
};
let children: Vec<ChildRef> = children
.iter()
.map(|child| ChildRef {
id: *child,
number: self.number_for_identity(child).unwrap_or_default(),
display: self.label_ref(child),
})
.collect();
ChildMembership {
parent: issue,
child_count: children.len(),
children,
}
}
pub fn parent_of(&self, issue: u32) -> Option<u32> {
self.parent_ref_of(issue).and_then(|parent| {
parent
.as_number()
.or_else(|| self.number_for_identity(parent))
})
}
pub fn label(&self, issue: u32) -> String {
self.identity_for_number(issue)
.map_or_else(|| format!("#{issue}"), |identity| self.label_ref(&identity))
}
fn label_ref(&self, identity: &IssueRef) -> String {
self.labels
.get(identity)
.cloned()
.unwrap_or_else(|| identity.to_string())
}
pub fn status_of(&self, issue: u32) -> Option<&str> {
self.identity_for_number(issue)
.and_then(|identity| self.statuses.get(&identity))
.map(String::as_str)
}
pub fn contains(&self, issue: u32) -> bool {
self.identity_for_number(issue).is_some()
}
pub fn contains_ref(&self, reference: &IssueRef) -> bool {
self.labels.contains_key(reference)
}
pub fn unresolved(&self) -> Vec<UnresolvedParentState> {
let mut out: Vec<UnresolvedParentState> = self
.parent_by_child
.iter()
.filter(|(_, parent)| !self.labels.contains_key(parent))
.map(|(child, parent)| UnresolvedParentState {
child: self.number_for_identity(child).unwrap_or_default(),
recorded_parent: *parent,
source_file: self.filenames.get(child).cloned().unwrap_or_default(),
reason: unresolved_reason(
self.number_for_identity(child).unwrap_or_default(),
*parent,
),
})
.collect();
out.sort_by_key(|s| s.child);
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedParentState {
pub child: u32,
pub recorded_parent: IssueRef,
pub source_file: String,
pub reason: String,
}
pub fn unresolved_reason(child: u32, parent: impl std::fmt::Display) -> String {
let parent = parent.to_string();
let parent = if parent.bytes().all(|byte| byte.is_ascii_digit()) {
format!("#{parent}")
} else {
parent
};
format!(
"{child}'s parent {parent} no longer exists (the reference is kept so it can be repaired)"
)
}
pub fn affected_by_deletion<N: HierarchyNode>(issues: &[N], parent: u32) -> Vec<ChildRef> {
let index = HierarchyIndex::build(issues);
let Some(parent_identity) = index.identity_for_number(parent) else {
return Vec::new();
};
index
.children_by_parent
.get(&parent_identity)
.map(|children| {
children
.iter()
.map(|child| ChildRef {
id: *child,
number: index.number_for_identity(child).unwrap_or_default(),
display: index.label_ref(child),
})
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParentEventKind {
ParentSet,
ParentChanged,
ParentCleared,
}
impl ParentEventKind {
pub fn as_str(self) -> &'static str {
match self {
ParentEventKind::ParentSet => "parent_set",
ParentEventKind::ParentChanged => "parent_changed",
ParentEventKind::ParentCleared => "parent_cleared",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParentEvent {
pub kind: ParentEventKind,
pub child: u32,
pub parent: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParentChange {
pub child: u32,
pub parent: Option<u32>,
pub previous: Option<u32>,
pub parent_ref: Option<IssueRef>,
pub event: Option<ParentEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HierarchyError {
Rejected(HierarchyValidationResult),
}
impl std::fmt::Display for HierarchyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HierarchyError::Rejected(result) if result.errors.is_empty() => {
f.write_str("hierarchy validation failed")
}
HierarchyError::Rejected(result) => f.write_str(&render_diagnostics(
"E-tix-hierarchy",
result
.errors
.iter()
.map(|e| (e.kind.as_str(), e.message.as_str())),
)),
}
}
}
impl std::error::Error for HierarchyError {}
impl From<HierarchyError> for TixError {
fn from(err: HierarchyError) -> TixError {
TixError::Refused(err.to_string())
}
}
pub fn set_parent(
index: &HierarchyIndex,
child: u32,
parent: u32,
) -> Result<ParentChange, HierarchyError> {
set_parent_ref(
index,
index
.identity_for_number(child)
.unwrap_or(IssueRef::Number(child)),
index
.identity_for_number(parent)
.unwrap_or(IssueRef::Number(parent)),
)
}
pub fn set_parent_ref(
index: &HierarchyIndex,
child: IssueRef,
parent: IssueRef,
) -> Result<ParentChange, HierarchyError> {
let result = validate_proposed_ref(index, child, Some(parent));
if result.blocking {
return Err(HierarchyError::Rejected(result));
}
let child_number = index
.number_for_identity(&child)
.or_else(|| child.as_number())
.unwrap_or_default();
let parent_number = index
.number_for_identity(&parent)
.or_else(|| parent.as_number())
.unwrap_or_default();
let previous = index.parent_ref_for(&child).copied();
if previous == Some(parent) {
return Ok(ParentChange {
child: child_number,
parent: Some(parent_number),
previous: previous.and_then(|reference| index.number_for_identity(&reference)),
parent_ref: Some(parent),
event: None,
});
}
let kind = if previous.is_some() {
ParentEventKind::ParentChanged
} else {
ParentEventKind::ParentSet
};
Ok(ParentChange {
child: child_number,
parent: Some(parent_number),
previous: previous.and_then(|reference| index.number_for_identity(&reference)),
parent_ref: Some(parent),
event: Some(ParentEvent {
kind,
child: child_number,
parent: Some(parent_number),
}),
})
}
pub fn change_parent(
index: &HierarchyIndex,
child: u32,
parent: u32,
) -> Result<ParentChange, HierarchyError> {
set_parent(index, child, parent)
}
pub fn change_parent_ref(
index: &HierarchyIndex,
child: IssueRef,
parent: IssueRef,
) -> Result<ParentChange, HierarchyError> {
set_parent_ref(index, child, parent)
}
pub fn clear_parent(index: &HierarchyIndex, child: u32) -> Result<ParentChange, HierarchyError> {
clear_parent_ref(
index,
index
.identity_for_number(child)
.unwrap_or(IssueRef::Number(child)),
)
}
pub fn clear_parent_ref(
index: &HierarchyIndex,
child: IssueRef,
) -> Result<ParentChange, HierarchyError> {
let result = validate_proposed_ref(index, child, None);
if result.blocking {
return Err(HierarchyError::Rejected(result));
}
if index.parent_ref_for(&child).is_none() {
let child_number = index.number_for_identity(&child).unwrap_or_default();
return Ok(ParentChange {
child: child_number,
parent: None,
previous: None,
parent_ref: None,
event: None,
});
}
let child_number = index.number_for_identity(&child).unwrap_or_default();
let previous = index
.parent_ref_for(&child)
.and_then(|reference| index.number_for_identity(reference));
Ok(ParentChange {
child: child_number,
parent: None,
previous,
parent_ref: None,
event: Some(ParentEvent {
kind: ParentEventKind::ParentCleared,
child: child_number,
parent: None,
}),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HierarchyDiagnosticKind {
SelfParent,
Cycle,
UnknownChild,
UnknownParent,
AmbiguousTarget,
DuplicateIdentity,
UnresolvedParent,
}
impl HierarchyDiagnosticKind {
pub fn as_str(self) -> &'static str {
match self {
HierarchyDiagnosticKind::SelfParent => "self_parent",
HierarchyDiagnosticKind::UnresolvedParent => "unresolved_parent",
HierarchyDiagnosticKind::Cycle => "cycle",
HierarchyDiagnosticKind::UnknownChild => "unknown_child",
HierarchyDiagnosticKind::UnknownParent => "unknown_parent",
HierarchyDiagnosticKind::AmbiguousTarget => "ambiguous_target",
HierarchyDiagnosticKind::DuplicateIdentity => "duplicate_identity",
}
}
}
fn alias_error_text(error: &crate::id::AliasError) -> String {
match error {
crate::id::AliasError::Missing(value) => format!("`{value}` resolves to no issue"),
crate::id::AliasError::Ambiguous { alias, candidates } => format!(
"`{alias}` is ambiguous: {}",
candidates
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
),
crate::id::AliasError::DuplicateId { id, numbers } => {
format!("id `{id}` is claimed by issues {numbers:?}")
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HierarchyDiagnostic {
pub kind: HierarchyDiagnosticKind,
pub issue: u32,
pub field: String,
pub message: String,
pub source_file: Option<String>,
pub path: Option<Vec<u32>>,
}
impl HierarchyDiagnostic {
fn new(kind: HierarchyDiagnosticKind, issue: u32, message: String) -> Self {
Self {
kind,
issue,
field: PARENT_KEY.to_string(),
message,
source_file: None,
path: None,
}
}
fn with_source_file(mut self, filename: impl Into<String>) -> Self {
self.source_file = Some(filename.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HierarchyValidationResult {
pub errors: Vec<HierarchyDiagnostic>,
pub blocking: bool,
}
impl HierarchyValidationResult {
pub fn clean() -> Self {
Self {
errors: Vec::new(),
blocking: false,
}
}
pub fn blocking(diagnostic: HierarchyDiagnostic) -> Self {
Self {
blocking: true,
errors: vec![diagnostic],
}
}
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
pub fn kinds(&self) -> Vec<HierarchyDiagnosticKind> {
self.errors.iter().map(|e| e.kind).collect()
}
}
pub fn validate_proposed(
index: &HierarchyIndex,
child: u32,
parent: Option<u32>,
) -> HierarchyValidationResult {
validate_proposed_ref(
index,
index
.identity_for_number(child)
.unwrap_or(IssueRef::Number(child)),
parent.map(|parent| {
index
.identity_for_number(parent)
.unwrap_or(IssueRef::Number(parent))
}),
)
}
pub fn validate_proposed_ref(
index: &HierarchyIndex,
child: IssueRef,
parent: Option<IssueRef>,
) -> HierarchyValidationResult {
let child_number = index
.number_for_identity(&child)
.or_else(|| child.as_number())
.unwrap_or_default();
if !index.contains_ref(&child) {
return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
HierarchyDiagnosticKind::UnknownChild,
child_number,
format!("{PARENT_KEY}: child {} does not exist", child.human()),
));
}
let Some(parent) = parent else {
return HierarchyValidationResult::clean();
};
let parent_number = index
.number_for_identity(&parent)
.or_else(|| parent.as_number())
.unwrap_or_default();
if !index.contains_ref(&parent) {
return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
HierarchyDiagnosticKind::UnknownParent,
child_number,
format!("{PARENT_KEY}: {} does not exist", parent.human()),
));
}
if child == parent {
return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
HierarchyDiagnosticKind::SelfParent,
child_number,
format!("{PARENT_KEY}: an issue cannot be its own parent"),
));
}
match cycle_path_ref(index, child, parent) {
Some(path) => {
let mut diagnostic = HierarchyDiagnostic::new(
HierarchyDiagnosticKind::Cycle,
child_number,
format!(
"{PARENT_KEY}: setting parent {} closes a cycle ({})",
parent_number,
render_path(&path)
),
);
diagnostic.path = Some(path);
HierarchyValidationResult::blocking(diagnostic)
}
None => HierarchyValidationResult::clean(),
}
}
pub fn change_detail<B, A>(before: Option<B>, after: Option<A>) -> String
where
B: Into<IssueRef>,
A: Into<IssueRef>,
{
let render = |reference: Option<IssueRef>| match reference {
Some(reference) => reference.human(),
None => "none".to_string(),
};
format!(
"parent {} -> {}",
render(before.map(Into::into)),
render(after.map(Into::into))
)
}
pub fn validate_new_parent(index: &HierarchyIndex, parent: u32) -> Result<(), HierarchyError> {
if index.contains(parent) {
return Ok(());
}
Err(HierarchyError::Rejected(
HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
HierarchyDiagnosticKind::UnknownParent,
NO_ISSUE_YET,
format!("{PARENT_KEY}: #{parent} does not exist"),
)),
))
}
pub const NO_ISSUE_YET: u32 = u32::MAX;
fn cycle_path_ref(index: &HierarchyIndex, child: IssueRef, parent: IssueRef) -> Option<Vec<u32>> {
let mut path = vec![index.number_for_identity(&child).unwrap_or_default()];
let mut visited: BTreeSet<IssueRef> = BTreeSet::new();
visited.insert(child);
let mut current = parent;
loop {
if !visited.insert(current) {
let current_number = index.number_for_identity(¤t).unwrap_or_default();
let start = path
.iter()
.position(|number| *number == current_number)
.unwrap_or(0);
return Some(path[start..].to_vec());
}
path.push(index.number_for_identity(¤t).unwrap_or_default());
match index.parent_ref_for(¤t).copied() {
Some(next) if index.contains_ref(&next) => current = next,
_ => return None,
}
}
}
fn render_path(path: &[u32]) -> String {
path.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(" -> ")
}
pub fn validate_hierarchy<N: HierarchyNode>(issues: &[N]) -> HierarchyValidationResult {
let index = HierarchyIndex::build(issues);
let mut errors: Vec<HierarchyDiagnostic> = index.alias_diagnostics().to_vec();
let mut dangling: Vec<HierarchyDiagnostic> = index
.unresolved()
.into_iter()
.map(|state| {
HierarchyDiagnostic::new(
HierarchyDiagnosticKind::UnresolvedParent,
state.child,
state.reason,
)
.with_source_file(state.source_file)
})
.collect();
errors.append(&mut dangling);
let mut self_parents: Vec<HierarchyDiagnostic> = issues
.iter()
.filter(|issue| {
let reference = hierarchy_ref(*issue);
index
.parent_ref_for(&reference)
.is_some_and(|parent| *parent == reference)
})
.map(|issue| {
HierarchyDiagnostic::new(
HierarchyDiagnosticKind::SelfParent,
issue.number(),
format!("{PARENT_KEY}: an issue cannot be its own parent"),
)
.with_source_file(issue.filename().unwrap_or(""))
})
.collect();
errors.append(&mut self_parents);
let mut seen: BTreeSet<IssueRef> = BTreeSet::new();
let mut sorted: Vec<IssueRef> = issues.iter().map(hierarchy_ref).collect();
sorted.sort_by_key(|reference| index.number_for_identity(reference).unwrap_or_default());
for child in sorted {
let Some(parent) = index.parent_ref_for(&child).copied() else {
continue;
};
if !index.contains_ref(&parent) || seen.contains(&child) {
continue;
}
if let Some(path) = cycle_path_ref(&index, child, parent) {
for node in &path {
if let Some(reference) = index
.refs_by_number
.get(node)
.and_then(|refs| (refs.len() == 1).then(|| refs[0]))
{
seen.insert(reference);
}
}
let child_number = index.number_for_identity(&child).unwrap_or_default();
let mut diagnostic = HierarchyDiagnostic::new(
HierarchyDiagnosticKind::Cycle,
child_number,
format!("{PARENT_KEY}: cycle ({})", render_path(&path)),
);
if let Some(filename) = index.filenames.get(&child) {
diagnostic.source_file = Some(filename.clone());
}
diagnostic.path = Some(path);
errors.push(diagnostic);
}
}
HierarchyValidationResult {
blocking: !errors.is_empty(),
errors,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompletionBasis {
DirectChildren,
}
impl CompletionBasis {
pub fn as_str(self) -> &'static str {
match self {
CompletionBasis::DirectChildren => "direct children",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParentRollup {
pub parent: u32,
pub direct_children: usize,
pub completed_children: usize,
pub progress: Progress,
pub completion_basis: CompletionBasis,
}
pub fn rollup(index: &HierarchyIndex, issue: u32, done_column: &str) -> ParentRollup {
let membership = index.children_of(issue);
let completed_children = membership
.children
.iter()
.filter(|child| index.status_of(child.number) == Some(done_column))
.count();
ParentRollup {
parent: issue,
direct_children: membership.child_count,
completed_children,
progress: Progress {
done: completed_children,
total: membership.child_count,
},
completion_basis: CompletionBasis::DirectChildren,
}
}
#[allow(dead_code)]
fn relationship_graph_of<'a, N: HierarchyNode + 'a>(issues: &'a [N]) -> RelationshipGraph<'a> {
let _ = issues;
RelationshipGraph::new(&[], "done")
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Node {
number: u32,
title: String,
status: String,
parent: Option<u32>,
}
impl Node {
fn new(number: u32, title: &str) -> Self {
Self {
number,
title: title.to_string(),
status: "todo".to_string(),
parent: None,
}
}
fn with_status(mut self, status: &str) -> Self {
self.status = status.to_string();
self
}
}
impl HierarchyNode for Node {
fn number(&self) -> u32 {
self.number
}
fn title(&self) -> &str {
&self.title
}
fn status(&self) -> &str {
&self.status
}
fn parent_number(&self) -> Option<u32> {
self.parent
}
}
fn node(number: u32, title: &str, status: &str, parent: Option<u32>) -> Node {
let mut n = Node::new(number, title).with_status(status);
n.parent = parent;
n
}
fn nodes(spec: &[(u32, &str, Option<u32>)]) -> Vec<Node> {
spec.iter()
.map(|(n, t, p)| {
let mut node = Node::new(*n, t);
node.parent = *p;
node
})
.collect()
}
#[test]
fn children_are_ordered_by_issue_number_regardless_of_input_order() {
let shuffled = nodes(&[
(9, "I", Some(1)),
(3, "C", Some(1)),
(7, "G", Some(1)),
(5, "E", Some(1)),
]);
let index = HierarchyIndex::build(&shuffled);
let membership = index.children_of(1);
assert_eq!(
membership
.children
.iter()
.map(|c| c.number)
.collect::<Vec<_>>(),
vec![3, 5, 7, 9],
"children must be ascending by number even when the input is not"
);
assert_eq!(membership.child_count, 4);
assert_eq!(membership.children[0].display, "#3 C");
}
#[test]
fn the_index_is_stable_across_repeated_builds_from_the_same_input() {
let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(1))]);
let first = HierarchyIndex::build(&issues);
let second = HierarchyIndex::build(&issues);
assert_eq!(first, second, "two builds of one snapshot must be equal");
assert_eq!(first.children_of(1), second.children_of(1));
assert_eq!(rollup(&first, 1, "done"), rollup(&second, 1, "done"));
}
#[test]
fn a_child_appears_under_exactly_one_parent() {
let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(2))]);
let index = HierarchyIndex::build(&issues);
let mut appearances = 0;
for parent in 1..=3 {
appearances += index.children_of(parent).child_count;
}
assert_eq!(
appearances, 2,
"each of the two parented issues must be counted exactly once"
);
assert_eq!(index.parent_of(2), Some(1));
assert_eq!(index.parent_of(1), None);
}
#[test]
fn no_child_list_is_stored_in_the_index() {
let issues = nodes(&[(1, "A", None), (2, "B", Some(1))]);
let index = HierarchyIndex::build(&issues);
let rebuilt = HierarchyIndex {
parent_by_child: index.parent_by_child.clone(),
children_by_parent: index.children_by_parent.clone(),
numbers: index.numbers.clone(),
refs_by_number: index.refs_by_number.clone(),
labels: index.labels.clone(),
statuses: index.statuses.clone(),
filenames: index.filenames.clone(),
aliases: index.aliases.clone(),
alias_diagnostics: index.alias_diagnostics.clone(),
};
assert_eq!(rebuilt, index, "the index is exactly these fields");
assert!(
index
.children_by_parent
.values()
.all(|v| v.iter().all(|n| index.labels.contains_key(n)))
);
}
#[test]
fn an_issue_with_no_children_returns_an_empty_membership_not_an_error() {
let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
let membership = index.children_of(1);
assert_eq!(membership.child_count, 0);
assert_eq!(membership.children, [] as [crate::hierarchy::ChildRef; 0]);
assert_eq!(membership.parent, 1);
}
#[test]
fn set_then_change_then_clear_produces_three_distinct_states() {
let issues = nodes(&[
(1, "P", None),
(2, "A", None),
(3, "B", None),
(4, "C", None),
]);
let index = HierarchyIndex::build(&issues);
let set = set_parent(&index, 2, 1).unwrap();
assert_eq!(set.parent, Some(1));
assert_eq!(
set.event.as_ref().map(|e| e.kind),
Some(ParentEventKind::ParentSet)
);
let mut after_set = issues.clone();
after_set[1].parent = Some(1);
let index2 = HierarchyIndex::build(&after_set);
let changed = change_parent(&index2, 2, 3).unwrap();
assert_eq!(changed.parent, Some(3));
assert_eq!(
changed.event.as_ref().map(|e| e.kind),
Some(ParentEventKind::ParentChanged)
);
let mut after_change = after_set.clone();
after_change[1].parent = Some(3);
let index3 = HierarchyIndex::build(&after_change);
let cleared = clear_parent(&index3, 2).unwrap();
assert_eq!(cleared.parent, None);
assert_eq!(
cleared.event.as_ref().map(|e| e.kind),
Some(ParentEventKind::ParentCleared)
);
let states = [set.parent, changed.parent, cleared.parent];
assert_eq!(states, [Some(1), Some(3), None]);
}
#[test]
fn clearing_a_child_with_no_parent_is_a_no_op_with_no_event() {
let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
let cleared = clear_parent(&index, 1).unwrap();
assert_eq!(cleared.parent, None);
assert!(
cleared.event.is_none(),
"a no-op must not pad the audit trail"
);
}
#[test]
fn setting_the_parent_the_child_already_has_is_a_no_op_with_no_event() {
let issues = nodes(&[(1, "P", None), (2, "C", Some(1))]);
let index = HierarchyIndex::build(&issues);
let again = set_parent(&index, 2, 1).unwrap();
assert_eq!(
again.parent,
Some(1),
"the resulting state is still reported"
);
assert!(
again.event.is_none(),
"an accepted mutation that changes nothing must not pad the audit trail"
);
}
#[test]
fn a_no_op_check_must_not_launder_a_self_parent() {
let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(2))]));
for (label, outcome) in [
("set_parent", set_parent(&index, 2, 2)),
("change_parent", change_parent(&index, 2, 2)),
] {
let err = outcome.expect_err(&format!(
"{label}(child=2, parent=2) must be refused even when 2 already records it"
));
assert!(
matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::SelfParent]),
"{label} refused for the wrong reason: {err:?}"
);
}
let clean = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
assert!(
set_parent(&clean, 2, 2).is_err(),
"the rule must still fire"
);
}
#[test]
fn a_no_op_check_must_not_launder_an_unknown_parent() {
let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(999))]));
for (label, outcome) in [
("set_parent", set_parent(&index, 2, 999)),
("change_parent", change_parent(&index, 2, 999)),
] {
let err = outcome.expect_err(&format!(
"{label}(child=2, parent=999) must be refused: #999 is not in the snapshot"
));
assert!(
matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownParent]),
"{label} refused for the wrong reason: {err:?}"
);
}
let clean = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
assert!(set_parent(&clean, 2, 2).is_err());
let ok = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(1))]));
assert!(
set_parent(&ok, 2, 1).unwrap().event.is_none(),
"no-op still a no-op"
);
}
#[test]
fn re_applying_the_same_parent_is_a_no_op_with_no_event() {
let issues = nodes(&[(1, "P", None), (2, "C", Some(1))]);
let index = HierarchyIndex::build(&issues);
let again = change_parent(&index, 2, 1).unwrap();
assert_eq!(again.parent, Some(1));
assert!(
again.event.is_none(),
"a retried command must not append a second event"
);
}
#[test]
fn a_second_parent_cannot_be_expressed() {
let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(1))]);
assert_eq!(
HierarchyIndex::build(&issues).children_of(1).child_count,
2,
"precondition: two children start under #1"
);
let mut after = issues.clone();
after[1].parent = Some(3);
let index2 = HierarchyIndex::build(&after);
assert_eq!(
index2.children_of(1).child_count,
1,
"repointing must move the child, never duplicate it"
);
assert_eq!(index2.parent_of(2), Some(3));
assert_eq!(index2.children_of(3).child_count, 1);
}
#[test]
fn a_self_parent_is_rejected_with_its_own_diagnostic_kind() {
let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
let result = validate_proposed(&index, 1, Some(1));
assert!(result.blocking);
assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::SelfParent]);
assert_eq!(result.errors[0].field, PARENT_KEY);
}
#[test]
fn an_unknown_parent_is_rejected_with_its_own_diagnostic_kind() {
let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
let result = validate_proposed(&index, 1, Some(99));
assert!(result.blocking);
assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownParent]);
assert!(
result.errors[0].message.contains("#99"),
"the message must name the missing parent: {}",
result.errors[0].message
);
}
#[test]
fn a_three_node_cycle_is_rejected_and_the_path_is_reported() {
let index = HierarchyIndex::build(&nodes(&[
(1, "A", Some(2)),
(2, "B", Some(3)),
(3, "C", None),
]));
let result = validate_proposed(&index, 3, Some(1));
assert!(result.blocking);
assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::Cycle]);
let path = result.errors[0]
.path
.as_ref()
.expect("a cycle must report its path");
assert_eq!(
index.parent_of(*path.last().expect("a cycle path is never empty")),
path.first().copied(),
"the reported path must be a closed loop"
);
assert_eq!(path.len(), 3, "the whole 3-node cycle must be shown");
assert!(
result.errors[0].message.contains("->"),
"the message must render the chain: {}",
result.errors[0].message
);
}
#[test]
fn a_deep_chain_is_walked_without_stack_overflow() {
const DEPTH: u32 = 10_000;
let mut issues: Vec<Node> = (1..=DEPTH + 1)
.map(|n| {
let mut node = Node::new(n, "n");
node.parent = if n == 1 || n == DEPTH + 1 {
None
} else {
Some(n - 1)
};
node
})
.collect();
let index = HierarchyIndex::build(&issues);
let outside_child = DEPTH + 1;
let result = validate_proposed(&index, outside_child, Some(DEPTH / 2));
assert!(
!result.blocking,
"pointing a new child into the middle of a 10,000-deep chain is not a \
cycle: {:?}",
result.errors
);
let root = DEPTH;
issues[root as usize - 1].parent = Some(root);
let index2 = HierarchyIndex::build(&issues);
let result = validate_proposed(&index2, 1, Some(root));
assert!(
result.blocking,
"a 10,000-deep chain closed into a cycle must be caught"
);
assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::Cycle]);
}
#[test]
fn a_clean_hierarchy_validates_with_no_findings() {
let issues = nodes(&[
(1, "Root", None),
(2, "A", Some(1)),
(3, "B", Some(1)),
(4, "C", Some(2)),
]);
let result = validate_hierarchy(&issues);
assert!(result.is_empty(), "unexpected: {:?}", result.errors);
assert!(!result.blocking);
}
#[test]
fn validate_hierarchy_reports_each_kind_over_a_snapshot() {
let issues = nodes(&[
(1, "Self", Some(1)),
(2, "Dangling", Some(99)),
(3, "A", Some(4)),
(4, "B", Some(3)),
]);
let result = validate_hierarchy(&issues);
assert!(result.blocking);
let kinds = result.kinds();
for expected in [
HierarchyDiagnosticKind::SelfParent,
HierarchyDiagnosticKind::UnresolvedParent,
HierarchyDiagnosticKind::Cycle,
] {
assert!(
kinds.contains(&expected),
"expected {expected:?} in {kinds:?}"
);
}
assert_eq!(
HierarchyDiagnosticKind::UnknownParent.as_str(),
"unknown_parent"
);
assert_eq!(
HierarchyDiagnosticKind::UnresolvedParent.as_str(),
"unresolved_parent",
"T022 step 1 fixes this spelling; a different one is a contract violation"
);
}
#[test]
fn a_rejected_proposal_yields_no_change_and_no_event() {
let index = HierarchyIndex::build(&nodes(&[(1, "A", None), (2, "B", Some(1))]));
let err = set_parent(&index, 1, 1).unwrap_err();
let HierarchyError::Rejected(result) = err;
assert!(result.blocking);
}
#[test]
fn an_unknown_child_is_refused_by_set_change_and_clear() {
let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
let missing = 99;
let result = validate_proposed(&index, missing, Some(1));
assert!(result.blocking);
assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownChild]);
assert_eq!(result.errors[0].issue, missing);
assert!(
result.errors[0].message.contains("#99"),
"the message must name the missing child: {}",
result.errors[0].message
);
for (label, outcome) in [
("set_parent", set_parent(&index, missing, 1)),
("change_parent", change_parent(&index, missing, 1)),
("clear_parent", clear_parent(&index, missing)),
] {
let err = outcome.expect_err(&format!("{label}(child={missing}) must be refused"));
assert!(
matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownChild]),
"{label} refused for the wrong reason: {err:?}"
);
}
assert!(set_parent(&index, 2, 1).is_ok());
assert!(clear_parent(&index, 2).unwrap().event.is_none());
}
#[test]
fn the_child_check_runs_before_the_parent_check() {
let index = HierarchyIndex::build(&nodes(&[(1, "P", None)]));
let result = validate_proposed(&index, 77, Some(88));
assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownChild]);
}
#[test]
fn an_unknown_child_is_not_laundered_into_a_clear_no_op() {
let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
assert!(clear_parent(&index, 42).is_err());
assert!(
clear_parent(&index, 1).is_ok(),
"a known child with no parent is still a no-op"
);
}
#[test]
fn a_parent_with_three_children_reports_counts_and_typed_progress() {
let issues = nodes(&[
(1, "Parent", None),
(2, "A", Some(1)),
(3, "B", Some(1)),
(4, "C", Some(1)),
]);
let mut issues = issues;
issues[1].status = "done".to_string();
let index = HierarchyIndex::build(&issues);
let rollup = rollup(&index, 1, "done");
assert_eq!(rollup.direct_children, 3);
assert_eq!(rollup.completed_children, 1);
assert_eq!(
rollup.progress,
Progress { done: 1, total: 3 },
"progress must be the typed value, not a formatted string"
);
assert_eq!(rollup.completion_basis, CompletionBasis::DirectChildren);
}
#[test]
fn an_issue_with_no_children_rolls_up_to_zero() {
let index = HierarchyIndex::build(&nodes(&[(1, "Lonely", None)]));
let rollup = rollup(&index, 1, "done");
assert_eq!(rollup.direct_children, 0);
assert_eq!(rollup.completed_children, 0);
assert_eq!(rollup.progress, Progress { done: 0, total: 0 });
}
#[test]
fn completion_is_resolved_through_the_supplied_column_not_a_literal() {
let issues = vec![
Node::new(1, "Parent"),
Node {
parent: Some(1),
..Node::new(2, "A").with_status("shipped")
},
Node {
parent: Some(1),
..Node::new(3, "B").with_status("done")
},
];
let index = HierarchyIndex::build(&issues);
assert_eq!(
index.children_of(1).child_count,
2,
"precondition: the parent must actually have two children — the first \
version of this test never set a parent, so it was measuring zero \
children and asserting a count of one"
);
assert_eq!(
rollup(&index, 1, "shipped").completed_children,
1,
"with 'shipped' as the completion column, A counts and B does not"
);
assert_eq!(
rollup(&index, 1, "done").completed_children,
1,
"and with 'done', B counts instead"
);
let membership = index.children_of(1);
let complete_under_shipped = membership
.children
.iter()
.find(|c| index.status_of(c.number) == Some("shipped"))
.map(|c| c.number);
let complete_under_done = membership
.children
.iter()
.find(|c| index.status_of(c.number) == Some("done"))
.map(|c| c.number);
assert_eq!(complete_under_shipped, Some(2));
assert_eq!(complete_under_done, Some(3));
assert_ne!(complete_under_shipped, complete_under_done);
}
#[test]
fn repeated_rollup_over_the_same_snapshot_returns_equal_values() {
let issues = nodes(&[(1, "P", None), (2, "A", Some(1)), (3, "B", Some(1))]);
let index = HierarchyIndex::build(&issues);
assert_eq!(rollup(&index, 1, "done"), rollup(&index, 1, "done"));
}
#[test]
fn an_unresolved_parent_retains_the_recorded_reference_for_repair() {
let issues = nodes(&[(1, "Child", Some(42))]);
let index = HierarchyIndex::build(&issues);
let unresolved = index.unresolved();
assert_eq!(unresolved.len(), 1);
assert_eq!(unresolved[0].child, 1);
assert_eq!(
unresolved[0].recorded_parent, 42,
"the reference must survive so it can be repaired, not be forgotten"
);
assert!(
unresolved[0].reason.contains("#42"),
"the reason must name the missing parent: {}",
unresolved[0].reason
);
}
#[test]
fn deletion_reports_affected_children_without_deleting_them() {
let issues = nodes(&[
(1, "Parent", None),
(2, "A", Some(1)),
(3, "B", Some(1)),
(4, "Elsewhere", None),
]);
let affected = affected_by_deletion(&issues, 1);
assert_eq!(
affected.iter().map(|c| c.number).collect::<Vec<_>>(),
vec![2, 3]
);
assert_eq!(issues.len(), 4);
assert_eq!(issues[1].parent, Some(1));
assert!(
affected_by_deletion(&issues, 4).is_empty(),
"an issue with no children affects nobody"
);
}
#[test]
fn the_whole_surface_runs_without_a_store_or_a_tempfile() {
let issues = nodes(&[
(1, "Root", None),
(2, "A", Some(1)),
(3, "B", Some(1)),
(4, "Orphan", Some(77)),
]);
let index = HierarchyIndex::build(&issues);
assert_eq!(index.children_of(1).child_count, 2);
assert_eq!(rollup(&index, 1, "done").direct_children, 2);
assert_eq!(index.unresolved().len(), 1);
assert!(!validate_hierarchy(&issues).is_empty());
assert!(set_parent(&index, 3, 2).is_ok());
}
#[test]
fn a_new_issues_parent_must_exist() {
let index = HierarchyIndex::build(&nodes(&[(1, "P", None)]));
assert!(
validate_new_parent(&index, 1).is_ok(),
"an existing issue is a valid parent"
);
let err = validate_new_parent(&index, 404).expect_err("a missing issue is not");
assert!(
matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownParent]),
"refused for the wrong reason: {err:?}"
);
let HierarchyError::Rejected(r) = err;
assert_eq!(r.errors[0].issue, NO_ISSUE_YET);
assert!(
r.errors[0].message.contains("404"),
"the message must name the missing parent: {}",
r.errors[0].message
);
}
#[test]
fn no_issue_yet_is_not_a_number_the_store_can_hand_out() {
assert_eq!(NO_ISSUE_YET, u32::MAX);
assert!(
NO_ISSUE_YET.checked_add(1).is_none(),
"the sentinel must be the one value the allocator cannot produce"
);
}
#[test]
fn t015_the_completion_basis_is_direct_children_and_the_rollup_says_so() {
let issues = vec![
node(1, "Parent", "todo", None),
node(2, "Done child", "done", Some(1)),
node(3, "Todo child", "todo", Some(1)),
node(4, "Grandchild of #2", "done", Some(2)),
];
let index = HierarchyIndex::build(&issues);
let r = rollup(&index, 1, "done");
assert_eq!(r.completion_basis, CompletionBasis::DirectChildren);
assert_eq!(r.direct_children, 2, "the direct children are counted");
assert_eq!(
r.completed_children, 1,
"only #2 is in the completion column"
);
assert_eq!(r.progress.done, 1);
assert_eq!(r.progress.total, 2);
assert_eq!(r.direct_children + r.progress.total, 4, "2 + 2, not 3");
}
#[test]
fn t015_a_grandchild_completed_does_not_complete_its_grandparent() {
let issues = vec![
node(1, "Grandparent", "todo", None),
node(2, "Child", "done", Some(1)),
node(3, "Grandchild", "todo", Some(2)),
];
let index = HierarchyIndex::build(&issues);
let r = rollup(&index, 1, "done");
assert_eq!(
r.completed_children, 1,
"the child counts, not the grandchild"
);
assert_eq!(r.progress.total, 1);
let child_rollup = rollup(&index, 2, "done");
assert_eq!(child_rollup.completed_children, 0);
assert_eq!(child_rollup.progress.total, 1);
}
#[test]
fn t015_direct_children_are_reported_separately_from_the_rollup() {
let issues: Vec<Node> = (1..=6)
.map(|n| {
node(
n,
&format!("Child {n}"),
if n % 2 == 0 { "done" } else { "todo" },
Some(1),
)
})
.collect();
let index = HierarchyIndex::build(&issues);
let r = rollup(&index, 1, "done");
assert_eq!(r.direct_children, 6);
assert_eq!(r.completed_children, 3);
assert_eq!(r.progress.done, 3);
assert_eq!(r.progress.total, 6);
}
#[test]
fn t020_dropping_a_parent_from_the_set_does_not_clear_the_childs_reference() {
let before = vec![
node(1, "Parent", "todo", None),
node(2, "Child", "todo", Some(1)),
];
let mut after = before.clone();
after.retain(|n| n.number != 1);
let index = HierarchyIndex::build(&after);
assert_eq!(
index.parent_of(2),
Some(1),
"the recorded reference must survive its target"
);
let unresolved = index.unresolved();
assert_eq!(unresolved.len(), 1, "and be diagnosed");
assert_eq!(unresolved[0].child, 2);
assert_eq!(unresolved[0].recorded_parent, 1);
assert_eq!(after[0].parent, Some(1));
}
#[test]
fn t020_repairing_a_child_resolves_it_with_no_cleanup_step() {
let dangling = vec![node(2, "Child", "todo", Some(1))];
let index = HierarchyIndex::build(&dangling);
assert_eq!(index.unresolved().len(), 1, "starts unresolved");
let repaired = vec![
node(1, "Parent", "todo", None),
node(2, "Child", "todo", Some(1)),
];
let index = HierarchyIndex::build(&repaired);
assert!(
index.unresolved().is_empty(),
"the unresolved state disappears because it is derived, not stored"
);
assert_eq!(index.parent_of(2), Some(1), "and the parent now resolves");
assert!(index.contains(1), "and the target is in the index");
let repointed = vec![
node(1, "Parent", "todo", None),
node(2, "Child", "todo", Some(9)),
node(9, "Other", "todo", None),
];
let index = HierarchyIndex::build(&repointed);
assert_eq!(
index.unresolved(),
[] as [crate::hierarchy::UnresolvedParentState; 0]
);
assert_eq!(index.parent_of(2), Some(9));
}
#[test]
fn t020_unresolved_is_ordered_by_child_so_a_lint_run_is_deterministic() {
let forward = vec![
node(1, "A", "todo", Some(99)),
node(2, "B", "todo", Some(98)),
node(3, "C", "todo", Some(97)),
];
let mut reversed = forward.clone();
reversed.reverse();
let a = HierarchyIndex::build(&forward).unresolved();
let b = HierarchyIndex::build(&reversed).unresolved();
assert_eq!(a, b, "the report must not depend on the read order");
assert_eq!(
a.iter().map(|s| s.child).collect::<Vec<_>>(),
vec![1, 2, 3],
"and it is ordered by child number"
);
}
#[test]
fn t020_a_resolved_parent_is_never_reported_unresolved() {
let clean = vec![
node(1, "Parent", "todo", None),
node(2, "Child", "todo", Some(1)),
node(3, "Grandchild", "todo", Some(2)),
];
let index = HierarchyIndex::build(&clean);
assert!(
index.unresolved().is_empty(),
"a clean hierarchy reports nothing: {:?}",
index.unresolved()
);
}
}