use std::collections::{HashMap, HashSet};
use std::fmt;
use indexmap::IndexMap;
use crate::config::{Config, EpicConfiguration, EpicId, ProjectId};
use crate::cycle_store::DEFAULT_CYCLE_NAME;
use crate::error::{Result, TixError};
use crate::planning::{Planner, Progress};
use crate::relationships::RelationshipGraph;
pub use crate::projects::{IssueKey, MemberRef, Patch};
pub const EPICS_KEY: &str = "epics";
pub trait EpicNode {
fn number(&self) -> u32;
fn title(&self) -> &str;
fn status(&self) -> &str;
fn epic_ids(&self) -> &[String];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicRef {
pub id: EpicId,
pub name: String,
pub display: String,
}
impl EpicRef {
#[must_use]
pub fn new(id: &EpicId, name: &str) -> Self {
Self {
id: id.clone(),
name: name.to_string(),
display: epic_display(id, name),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Epic {
pub id: EpicId,
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub owner: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub weight: Option<i64>,
pub parent: Option<String>,
pub projects: Vec<String>,
pub milestone: Option<String>,
pub cycle: Option<String>,
pub members: Vec<MemberRef>,
pub member_count: usize,
pub children: Vec<EpicRef>,
pub child_count: usize,
}
impl Epic {
fn from_configuration(
id: EpicId,
record: &EpicConfiguration,
members: Vec<MemberRef>,
children: Vec<EpicRef>,
) -> Self {
Self {
id,
name: record.name.clone(),
description: record.description.clone(),
status: record.status.clone(),
owner: record.owner.clone(),
start_date: record.start_date.clone(),
due_date: record.due_date.clone(),
weight: record.weight,
parent: record.parent.clone(),
projects: record.projects.clone(),
milestone: record.milestone.clone(),
cycle: record.cycle.clone(),
member_count: members.len(),
members,
child_count: children.len(),
children,
}
}
}
#[must_use]
pub fn epic_display(id: &EpicId, name: &str) -> String {
format!("{name} ({id})")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EpicDiagnosticKind {
DuplicateName,
UnknownEpic,
UnknownIssue,
InvalidDates,
InvalidWeight,
InvalidReference,
SelfParent,
Cycle,
}
impl EpicDiagnosticKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
EpicDiagnosticKind::DuplicateName => "duplicate_name",
EpicDiagnosticKind::UnknownEpic => "unknown_epic",
EpicDiagnosticKind::UnknownIssue => "unknown_issue",
EpicDiagnosticKind::InvalidDates => "invalid_dates",
EpicDiagnosticKind::InvalidWeight => "invalid_weight",
EpicDiagnosticKind::InvalidReference => "invalid_reference",
EpicDiagnosticKind::SelfParent => "self_parent",
EpicDiagnosticKind::Cycle => "cycle",
}
}
}
impl fmt::Display for EpicDiagnosticKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn unknown_epic_reason(reference: &str) -> String {
format!("no epic matches `{reference}`; an epic is selected by its stable id or by name")
}
#[must_use]
pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
format!("issue `{selector}` is not in this store")
}
#[must_use]
pub fn invalid_dates_reason(detail: &str) -> String {
format!("epic dates are invalid: {detail}")
}
#[must_use]
pub fn invalid_weight_reason(value: i64) -> String {
format!("epic weight must be a positive integer; got {value}")
}
#[must_use]
pub fn invalid_reference_reason(field: &str, value: &str) -> String {
format!(
"`epics.<id>.{field}` entry `{value}` is not a well-formed id; \
an epic references the target by its stable id"
)
}
#[must_use]
pub fn duplicate_name_reason(name: &str) -> String {
if name.trim().is_empty() {
"an epic name must not be blank".to_string()
} else if name.trim() != name {
format!("epic name `{name}` has leading or trailing whitespace")
} else {
format!("epic name `{name}` is already in use; epic names must be unique")
}
}
#[must_use]
pub fn self_parent_reason(epic: &EpicId) -> String {
format!("epic `{epic}` cannot be its own parent")
}
#[must_use]
pub fn cycle_reason(epic: &EpicId, path: &[EpicId]) -> String {
let rendered: Vec<&str> = path.iter().map(EpicId::as_str).collect();
format!(
"parent would close an epic cycle: `{epic}` -> {}",
rendered.join(" -> ")
)
}
#[must_use]
pub fn unresolved_parent_reason(epic: &EpicId, recorded_parent: &str) -> String {
format!(
"epic `{epic}` records parent `{recorded_parent}`, which no longer exists \
(the reference is kept so it can be repaired)"
)
}
#[must_use]
pub fn reference_detail(epic_id: &str, referenced: bool) -> String {
if referenced {
format!("epic `{epic_id}` referenced")
} else {
format!("epic `{epic_id}` dereferenced")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EpicError {
UnknownEpic {
reference: String,
},
UnknownIssue {
selector: u32,
},
DuplicateName {
name: String,
},
InvalidDates {
detail: String,
},
InvalidWeight {
value: i64,
},
InvalidReference {
field: &'static str,
value: String,
},
SelfParent {
epic: EpicId,
},
Cycle {
epic: EpicId,
path: Vec<EpicId>,
},
}
impl EpicError {
#[must_use]
pub fn kind(&self) -> EpicDiagnosticKind {
match self {
EpicError::UnknownEpic { .. } => EpicDiagnosticKind::UnknownEpic,
EpicError::UnknownIssue { .. } => EpicDiagnosticKind::UnknownIssue,
EpicError::DuplicateName { .. } => EpicDiagnosticKind::DuplicateName,
EpicError::InvalidDates { .. } => EpicDiagnosticKind::InvalidDates,
EpicError::InvalidWeight { .. } => EpicDiagnosticKind::InvalidWeight,
EpicError::InvalidReference { .. } => EpicDiagnosticKind::InvalidReference,
EpicError::SelfParent { .. } => EpicDiagnosticKind::SelfParent,
EpicError::Cycle { .. } => EpicDiagnosticKind::Cycle,
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
EpicError::UnknownEpic { reference } => unknown_epic_reason(reference),
EpicError::UnknownIssue { selector } => unknown_issue_reason(selector),
EpicError::DuplicateName { name } => duplicate_name_reason(name),
EpicError::InvalidDates { detail } => invalid_dates_reason(detail),
EpicError::InvalidWeight { value } => invalid_weight_reason(*value),
EpicError::InvalidReference { field, value } => invalid_reference_reason(field, value),
EpicError::SelfParent { epic } => self_parent_reason(epic),
EpicError::Cycle { epic, path } => cycle_reason(epic, path),
}
}
}
impl fmt::Display for EpicError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.kind(), self.message())
}
}
impl std::error::Error for EpicError {}
impl From<EpicError> for TixError {
fn from(err: EpicError) -> TixError {
TixError::InvalidInput(err.to_string())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NewEpic {
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub owner: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub weight: Option<i64>,
pub projects: Vec<String>,
pub milestone: Option<String>,
pub cycle: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EpicEdit {
pub name: Patch<String>,
pub description: Patch<String>,
pub status: Patch<String>,
pub owner: Patch<String>,
pub start_date: Patch<String>,
pub due_date: Patch<String>,
pub weight: Patch<i64>,
pub parent: Patch<String>,
pub projects: Patch<Vec<String>>,
pub milestone: Patch<String>,
pub cycle: Patch<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicDeletion {
pub id: EpicId,
pub name: String,
pub affected: Vec<MemberRef>,
pub child_epics: Vec<EpicRef>,
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
fn validate_name(
registry: &IndexMap<String, EpicConfiguration>,
name: &str,
exclude: Option<&EpicId>,
) -> Result<(), EpicError> {
if name.trim().is_empty() || name.trim() != name {
return Err(EpicError::DuplicateName {
name: name.to_string(),
});
}
let normalized = name.to_lowercase();
let clash = registry.iter().any(|(raw, record)| {
let is_self = exclude.is_some_and(|id| raw.as_str() == id.as_str());
!is_self && record.name.to_lowercase() == normalized
});
if clash {
return Err(EpicError::DuplicateName {
name: name.to_string(),
});
}
Ok(())
}
fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), EpicError> {
crate::model::validate_issue_dates(start, due).map_err(|err| EpicError::InvalidDates {
detail: err.to_string(),
})
}
fn validate_project_ids(list: &[String]) -> Result<Vec<String>, EpicError> {
let mut out: Vec<String> = Vec::new();
for entry in list {
let id = ProjectId::new(entry).map_err(|_| EpicError::InvalidReference {
field: "projects",
value: entry.clone(),
})?;
if !out.iter().any(|stored| stored == id.as_str()) {
out.push(id.to_string());
}
}
Ok(out)
}
fn validate_weight(weight: i64) -> Result<(), EpicError> {
if weight > 0 {
return Ok(());
}
Err(EpicError::InvalidWeight { value: weight })
}
pub fn resolve_epic_id(
registry: &IndexMap<String, EpicConfiguration>,
reference: &str,
) -> Result<EpicId, EpicError> {
if let Ok(id) = EpicId::new(reference) {
if registry.contains_key(id.as_str()) {
return Ok(id);
}
}
let normalized = reference.trim().to_lowercase();
if let Some((raw, _)) = registry
.iter()
.find(|(_, record)| record.name.to_lowercase() == normalized)
{
return EpicId::new(raw).map_err(|_| EpicError::UnknownEpic {
reference: reference.to_string(),
});
}
Err(EpicError::UnknownEpic {
reference: reference.to_string(),
})
}
#[must_use]
pub(crate) fn dereference_needle(
registry: &IndexMap<String, EpicConfiguration>,
reference: &str,
) -> String {
resolve_epic_id(registry, reference).map_or_else(|_| reference.to_string(), |id| id.to_string())
}
fn recorded_children(registry: &IndexMap<String, EpicConfiguration>, id: &EpicId) -> Vec<EpicRef> {
registry
.iter()
.filter(|(_, record)| record.parent.as_deref() == Some(id.as_str()))
.filter_map(|(raw, record)| {
EpicId::new(raw)
.ok()
.map(|child| EpicRef::new(&child, &record.name))
})
.collect()
}
pub fn create_epic(config: &mut Config, new: &NewEpic) -> Result<Epic, EpicError> {
validate_name(&config.epics, &new.name, None)?;
validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
if let Some(weight) = new.weight {
validate_weight(weight)?;
}
let projects = validate_project_ids(&new.projects)?;
let id = config.allocate_epic_id();
let record = EpicConfiguration {
name: new.name.clone(),
description: normalize_optional(new.description.clone()),
status: normalize_optional(new.status.clone()),
owner: normalize_optional(new.owner.clone()),
start_date: new.start_date.clone(),
due_date: new.due_date.clone(),
weight: new.weight,
projects,
milestone: normalize_optional(new.milestone.clone()),
cycle: normalize_optional(new.cycle.clone()),
..EpicConfiguration::default()
};
config.epics.insert(id.to_string(), record.clone());
Ok(Epic::from_configuration(
id,
&record,
Vec::new(),
Vec::new(),
))
}
pub fn edit_epic(config: &mut Config, reference: &str, edit: &EpicEdit) -> Result<Epic, EpicError> {
let id = resolve_epic_id(&config.epics, reference)?;
let mut updated =
config
.epics
.get(id.as_str())
.cloned()
.ok_or_else(|| EpicError::UnknownEpic {
reference: reference.to_string(),
})?;
match &edit.name {
Patch::Unchanged => {}
Patch::Clear => {
return Err(EpicError::DuplicateName {
name: String::new(),
});
}
Patch::Set(name) => {
validate_name(&config.epics, name, Some(&id))?;
}
}
if let Patch::Set(weight) = &edit.weight {
validate_weight(*weight)?;
}
let proposed_parent = match &edit.parent {
Patch::Unchanged => None,
Patch::Clear => Some(None),
Patch::Set(parent) => Some(Some(validate_epic_parent(&config.epics, &id, parent)?)),
};
let proposed_projects = match &edit.projects {
Patch::Unchanged => None,
Patch::Clear => Some(Vec::new()),
Patch::Set(list) => Some(validate_project_ids(list)?),
};
if let Patch::Set(name) = &edit.name {
updated.name.clone_from(name);
}
apply_optional(&edit.description, &mut updated.description);
apply_optional(&edit.status, &mut updated.status);
apply_optional(&edit.owner, &mut updated.owner);
apply_date(&edit.start_date, &mut updated.start_date);
apply_date(&edit.due_date, &mut updated.due_date);
if let Patch::Set(weight) = &edit.weight {
updated.weight = Some(*weight);
}
if let Patch::Clear = &edit.weight {
updated.weight = None;
}
if let Some(parent) = proposed_parent {
updated.parent = parent.map(|id| id.to_string());
}
if let Some(projects) = proposed_projects {
updated.projects = projects;
}
apply_optional(&edit.milestone, &mut updated.milestone);
apply_optional(&edit.cycle, &mut updated.cycle);
validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
if let Some(record) = config.epics.get_mut(id.as_str()) {
*record = updated.clone();
}
Ok(Epic::from_configuration(
id,
&updated,
Vec::new(),
Vec::new(),
))
}
fn apply_optional(patch: &Patch<String>, slot: &mut Option<String>) {
match patch {
Patch::Unchanged => {}
Patch::Clear => *slot = None,
Patch::Set(value) => *slot = normalize_optional(Some(value.clone())),
}
}
fn apply_date(patch: &Patch<String>, slot: &mut Option<String>) {
match patch {
Patch::Unchanged => {}
Patch::Clear => *slot = None,
Patch::Set(value) => *slot = Some(value.clone()),
}
}
pub fn delete_epic<N: EpicNode>(
config: &mut Config,
reference: &str,
issues: &[N],
) -> Result<EpicDeletion, EpicError> {
let id = resolve_epic_id(&config.epics, reference)?;
let record = config
.epics
.get(id.as_str())
.cloned()
.ok_or_else(|| EpicError::UnknownEpic {
reference: reference.to_string(),
})?;
let affected = EpicIndex::build(config, issues)
.epic(&id)
.map(|epic| epic.members.clone())
.unwrap_or_default();
let child_epics = recorded_children(&config.epics, &id);
config.epics.shift_remove(id.as_str());
Ok(EpicDeletion {
id,
name: record.name,
affected,
child_epics,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicMembership {
pub epic_id: EpicId,
pub members: Vec<MemberRef>,
pub member_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicIndex {
epics: IndexMap<EpicId, Epic>,
buckets: HashMap<EpicId, Vec<IssueKey>>,
parents: HashMap<EpicId, EpicId>,
children: HashMap<EpicId, Vec<EpicId>>,
missing: Vec<EpicReferenceResolution>,
labels: HashMap<IssueKey, String>,
statuses: HashMap<IssueKey, String>,
}
impl EpicIndex {
pub fn build<N: EpicNode>(config: &Config, issues: &[N]) -> Self {
Self::build_from_registry(&config.epics, issues)
}
pub fn build_from_registry<N: EpicNode>(
registry: &IndexMap<String, EpicConfiguration>,
issues: &[N],
) -> Self {
let known: HashSet<String> = registry.keys().cloned().collect();
let mut labels: HashMap<IssueKey, String> = HashMap::new();
let mut statuses: HashMap<IssueKey, String> = HashMap::new();
let mut buckets: HashMap<EpicId, Vec<IssueKey>> = HashMap::new();
let mut missing: Vec<EpicReferenceResolution> = Vec::new();
for issue in issues {
let key = IssueKey::new(issue.number());
labels.insert(
key,
crate::hierarchy::display_label(issue.number(), issue.title()),
);
statuses.insert(key, issue.status().to_string());
let mut seen: HashSet<&str> = HashSet::new();
for raw in issue.epic_ids() {
if !seen.insert(raw.as_str()) {
continue;
}
let holder = ReferenceHolder::Issue(key);
match EpicId::new(raw) {
Ok(id) if known.contains(id.as_str()) => {
buckets.entry(id).or_default().push(key);
}
Ok(_) => missing.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Epic,
target: raw.clone(),
reason: missing_epic_reason(&holder, raw),
}),
Err(_) => missing.push(EpicReferenceResolution {
holder,
target_kind: EpicReferenceKind::Issue,
target: raw.clone(),
reason: malformed_epic_reason(issue.number(), raw),
}),
}
}
}
for members in buckets.values_mut() {
members.sort_unstable();
}
let mut children: HashMap<EpicId, Vec<EpicId>> = HashMap::new();
let mut ordered: Vec<(EpicId, EpicConfiguration)> = Vec::new();
let parents: HashMap<EpicId, EpicId> = resolved_parents(registry);
for (raw, record) in registry {
let Ok(id) = EpicId::new(raw) else {
continue;
};
if let Some(parent) = parents.get(&id) {
children.entry(parent.clone()).or_default().push(id.clone());
}
ordered.push((id, record.clone()));
}
let mut epics: IndexMap<EpicId, Epic> = IndexMap::new();
for (id, record) in ordered {
let members: Vec<MemberRef> = buckets
.get(&id)
.map(|keys| {
keys.iter()
.map(|key| MemberRef {
issue: *key,
display: labels.get(key).cloned().unwrap_or_else(|| key.to_string()),
})
.collect()
})
.unwrap_or_default();
let kid_refs: Vec<EpicRef> = children
.get(&id)
.map(|ids| {
ids.iter()
.filter_map(|child| {
registry
.get(child.as_str())
.map(|record| EpicRef::new(child, &record.name))
})
.collect()
})
.unwrap_or_default();
epics.insert(
id.clone(),
Epic::from_configuration(id, &record, members, kid_refs),
);
}
missing.sort_by(reference_order);
Self {
epics,
buckets,
parents,
children,
missing,
labels,
statuses,
}
}
#[must_use]
pub fn buckets(&self) -> &HashMap<EpicId, Vec<IssueKey>> {
&self.buckets
}
#[must_use]
pub fn memberships_by_issue(&self) -> HashMap<u32, Vec<String>> {
let order: HashMap<&str, usize> = self
.epics
.keys()
.enumerate()
.map(|(position, id)| (id.as_str(), position))
.collect();
let mut memberships: HashMap<u32, Vec<String>> = HashMap::new();
for (id, keys) in &self.buckets {
for key in keys {
memberships
.entry(key.number())
.or_default()
.push(id.to_string());
}
}
for ids in memberships.values_mut() {
ids.sort_by_key(|id| order.get(id.as_str()).copied().unwrap_or(usize::MAX));
}
memberships
}
#[must_use]
pub fn bucket(&self, id: &EpicId) -> Option<&[IssueKey]> {
if !self.epics.contains_key(id) {
return None;
}
Some(self.buckets.get(id).map_or(&[][..], Vec::as_slice))
}
#[must_use]
pub fn epics(&self) -> Vec<&Epic> {
self.epics.values().collect()
}
#[must_use]
pub fn epic(&self, id: &EpicId) -> Option<&Epic> {
self.epics.get(id)
}
#[must_use]
pub fn label(&self, key: IssueKey) -> Option<&str> {
self.labels.get(&key).map(String::as_str)
}
#[must_use]
pub fn status_of(&self, key: IssueKey) -> Option<&str> {
self.statuses.get(&key).map(String::as_str)
}
#[must_use]
pub fn missing(&self) -> &[EpicReferenceResolution] {
&self.missing
}
pub fn resolve(&self, reference: &str) -> Result<&Epic, EpicError> {
if let Ok(id) = EpicId::new(reference) {
if let Some(epic) = self.epics.get(&id) {
return Ok(epic);
}
}
let normalized = reference.trim().to_lowercase();
self.epics
.values()
.find(|epic| epic.name.to_lowercase() == normalized)
.ok_or_else(|| EpicError::UnknownEpic {
reference: reference.to_string(),
})
}
pub fn membership(&self, reference: &str) -> Result<EpicMembership, EpicError> {
let epic = self.resolve(reference)?;
Ok(EpicMembership {
epic_id: epic.id.clone(),
members: epic.members.clone(),
member_count: epic.member_count,
})
}
#[must_use]
pub fn into_buckets(self) -> HashMap<EpicId, Vec<IssueKey>> {
self.buckets
}
#[must_use]
pub fn into_missing(self) -> Vec<EpicReferenceResolution> {
self.missing
}
#[must_use]
pub fn into_epics(self) -> Vec<Epic> {
self.epics.into_values().collect()
}
}
#[must_use]
pub fn membership_index<N: EpicNode>(
config: &Config,
issues: &[N],
) -> HashMap<EpicId, Vec<IssueKey>> {
EpicIndex::build(config, issues).into_buckets()
}
#[must_use]
pub fn memberships_by_issue<N: EpicNode>(
config: &Config,
issues: &[N],
) -> HashMap<u32, Vec<String>> {
EpicIndex::build(config, issues).memberships_by_issue()
}
pub fn membership<N: EpicNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<EpicMembership, EpicError> {
EpicIndex::build(config, issues).membership(reference)
}
#[must_use]
pub fn list_epics<N: EpicNode>(config: &Config, issues: &[N]) -> Vec<Epic> {
EpicIndex::build(config, issues).into_epics()
}
pub fn resolve<N: EpicNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<Epic, EpicError> {
EpicIndex::build(config, issues).resolve(reference).cloned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedEpicParent {
pub epic: EpicId,
pub recorded_parent: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicHierarchy {
pub epic: EpicId,
pub recorded_parent: Option<String>,
pub parent: Option<EpicRef>,
pub children: Vec<EpicRef>,
pub child_count: usize,
pub ancestors: Vec<EpicRef>,
pub depth: usize,
pub unresolved: Option<UnresolvedEpicParent>,
}
fn epic_cycle_path(
parents: &HashMap<EpicId, EpicId>,
epic: &EpicId,
parent: &EpicId,
) -> Option<Vec<EpicId>> {
let mut path = vec![epic.clone()];
let mut visited: HashSet<EpicId> = HashSet::new();
visited.insert(epic.clone());
let mut current = parent.clone();
loop {
if current == *epic {
return Some(path);
}
if !visited.insert(current.clone()) {
return None;
}
path.push(current.clone());
{
let next = parents.get(¤t)?;
current = next.clone()
}
}
}
fn resolved_parents(registry: &IndexMap<String, EpicConfiguration>) -> HashMap<EpicId, EpicId> {
let known: HashSet<String> = registry.keys().cloned().collect();
let mut parents: HashMap<EpicId, EpicId> = HashMap::new();
for (raw, record) in registry {
let Ok(id) = EpicId::new(raw) else {
continue;
};
if let Some(parent) = record
.parent
.as_deref()
.and_then(|p| EpicId::new(p).ok())
.filter(|p| known.contains(p.as_str()))
{
parents.insert(id, parent);
}
}
parents
}
pub fn validate_epic_parent(
registry: &IndexMap<String, EpicConfiguration>,
epic: &EpicId,
parent_reference: &str,
) -> Result<EpicId, EpicError> {
let parent = resolve_epic_id(registry, parent_reference)?;
if &parent == epic {
return Err(EpicError::SelfParent { epic: epic.clone() });
}
if let Some(path) = epic_cycle_path(&resolved_parents(registry), epic, &parent) {
return Err(EpicError::Cycle {
epic: epic.clone(),
path,
});
}
Ok(parent)
}
#[must_use]
pub fn validate_epic_chains(registry: &IndexMap<String, EpicConfiguration>) -> Vec<EpicError> {
let parents = resolved_parents(registry);
let mut out: Vec<EpicError> = Vec::new();
let mut ids: Vec<EpicId> = registry
.keys()
.filter_map(|raw| EpicId::new(raw).ok())
.collect();
ids.sort();
let mut seen: HashSet<EpicId> = HashSet::new();
for id in &ids {
if parents.get(id) == Some(id) {
seen.insert(id.clone());
out.push(EpicError::SelfParent { epic: id.clone() });
}
}
for id in &ids {
if seen.contains(id) {
continue;
}
let Some(parent) = parents.get(id) else {
continue;
};
if let Some(path) = epic_cycle_path(&parents, id, parent) {
for node in &path {
seen.insert(node.clone());
}
out.push(EpicError::Cycle {
epic: id.clone(),
path,
});
}
}
out.sort_by(|a, b| {
a.kind()
.cmp(&b.kind())
.then_with(|| chain_epic(a).cmp(&chain_epic(b)))
});
out
}
fn chain_epic(err: &EpicError) -> Option<&EpicId> {
match err {
EpicError::SelfParent { epic } | EpicError::Cycle { epic, .. } => Some(epic),
_ => None,
}
}
impl EpicIndex {
pub fn hierarchy(&self, reference: &str) -> Result<EpicHierarchy, EpicError> {
let epic = self.resolve(reference)?;
let id = epic.id.clone();
let parent = self.parents.get(&id).and_then(|parent| self.ref_of(parent));
let mut ancestors: Vec<EpicRef> = Vec::new();
let mut visited: HashSet<EpicId> = HashSet::new();
visited.insert(id.clone());
let mut current = self.parents.get(&id).cloned();
while let Some(node) = current {
if !visited.insert(node.clone()) {
break;
}
if let Some(reference) = self.ref_of(&node) {
ancestors.push(reference);
}
current = self.parents.get(&node).cloned();
}
ancestors.reverse();
let children: Vec<EpicRef> = self
.children
.get(&id)
.map(|ids| ids.iter().filter_map(|child| self.ref_of(child)).collect())
.unwrap_or_default();
let unresolved = epic.parent.as_deref().and_then(|recorded| {
let resolves = self
.parents
.get(&id)
.is_some_and(|p| p.as_str() == recorded);
if resolves {
return None;
}
Some(UnresolvedEpicParent {
epic: id.clone(),
recorded_parent: recorded.to_string(),
reason: unresolved_parent_reason(&id, recorded),
})
});
Ok(EpicHierarchy {
epic: id,
recorded_parent: epic.parent.clone(),
parent,
child_count: children.len(),
children,
depth: ancestors.len(),
ancestors,
unresolved,
})
}
fn ref_of(&self, id: &EpicId) -> Option<EpicRef> {
self.epics.get(id).map(|epic| EpicRef::new(id, &epic.name))
}
}
pub fn hierarchy<N: EpicNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<EpicHierarchy, EpicError> {
EpicIndex::build(config, issues).hierarchy(reference)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EpicReferenceKind {
Epic,
Project,
Issue,
Milestone,
Cycle,
}
impl EpicReferenceKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
EpicReferenceKind::Epic => "epic",
EpicReferenceKind::Project => "project",
EpicReferenceKind::Issue => "issue",
EpicReferenceKind::Milestone => "milestone",
EpicReferenceKind::Cycle => "cycle",
}
}
#[must_use]
pub fn diagnostic_kind(self) -> &'static str {
match self {
EpicReferenceKind::Epic => "missing_epic_reference",
EpicReferenceKind::Project => "missing_project_reference",
EpicReferenceKind::Issue => "missing_issue_reference",
EpicReferenceKind::Milestone => "missing_milestone_reference",
EpicReferenceKind::Cycle => "missing_cycle_reference",
}
}
}
impl fmt::Display for EpicReferenceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ReferenceHolder {
Epic(EpicId),
Issue(IssueKey),
}
impl fmt::Display for ReferenceHolder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReferenceHolder::Epic(id) => write!(f, "epic `{id}`"),
ReferenceHolder::Issue(key) => write!(f, "issue {key}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicReferenceResolution {
pub holder: ReferenceHolder,
pub target_kind: EpicReferenceKind,
pub target: String,
pub reason: String,
}
#[must_use]
pub fn missing_epic_reason(holder: &ReferenceHolder, target: &str) -> String {
format!(
"{holder} records epic reference `{target}`, which is not in the registry \
(the reference is kept so it can be repaired)"
)
}
#[must_use]
pub fn malformed_epic_reason(issue: u32, raw: &str) -> String {
format!(
"issue #{issue} records epic reference `{raw}`, which is not a well-formed epic id \
(ids are recorded, never names; the entry is kept so it can be repaired)"
)
}
#[must_use]
pub fn missing_project_reason(epic: &EpicId, target: &str) -> String {
format!(
"epic `{epic}` references project `{target}`, which is not in the project registry \
(the reference is kept so it can be repaired)"
)
}
#[must_use]
pub fn missing_milestone_reason(epic: &EpicId, target: &str) -> String {
format!(
"epic `{epic}` references milestone `{target}`, which is not in the milestone registry \
(the reference is kept so it can be repaired)"
)
}
#[must_use]
pub fn missing_cycle_reason(epic: &EpicId, target: &str) -> String {
format!(
"epic `{epic}` references cycle `{target}`, which is not in the cycle registry \
(the reference is kept so it can be repaired)"
)
}
fn reference_order(a: &EpicReferenceResolution, b: &EpicReferenceResolution) -> std::cmp::Ordering {
a.holder
.cmp(&b.holder)
.then_with(|| a.target_kind.cmp(&b.target_kind))
.then_with(|| a.target.cmp(&b.target))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReferenceTargets {
pub projects: HashSet<String>,
pub milestones: HashSet<String>,
pub cycles: HashSet<String>,
}
impl ReferenceTargets {
pub fn from_planner(planner: &Planner) -> Result<Self> {
let mut cycles: HashSet<String> = planner.cycle_names().into_iter().collect();
if planner.cycle(DEFAULT_CYCLE_NAME).is_some() {
cycles.insert(DEFAULT_CYCLE_NAME.to_string());
}
Ok(Self {
projects: planner.config().projects.keys().cloned().collect(),
milestones: planner.milestone_names()?.into_iter().collect(),
cycles,
})
}
#[must_use]
pub fn project_known(&self, id: &str) -> bool {
self.projects.contains(id)
}
#[must_use]
pub fn milestone_known(&self, name: &str) -> bool {
self.milestones.contains(name)
}
#[must_use]
pub fn cycle_known(&self, name: &str) -> bool {
self.cycles.contains(name)
}
}
#[must_use]
pub fn resolve_references<N: EpicNode>(
registry: &IndexMap<String, EpicConfiguration>,
issues: &[N],
targets: &ReferenceTargets,
) -> Vec<EpicReferenceResolution> {
let index = EpicIndex::build_from_registry(registry, issues);
let mut out: Vec<EpicReferenceResolution> = index.missing().to_vec();
for epic in index.epics() {
out.extend(index.missing_for(&epic.id, targets));
}
out.sort_by(reference_order);
out
}
impl EpicIndex {
#[must_use]
pub fn missing_for(
&self,
id: &EpicId,
targets: &ReferenceTargets,
) -> Vec<EpicReferenceResolution> {
let Some(epic) = self.epics.get(id) else {
return Vec::new();
};
let holder = ReferenceHolder::Epic(id.clone());
let mut out: Vec<EpicReferenceResolution> = Vec::new();
if let Some(recorded) = epic.parent.as_deref() {
let resolves = self.parents.get(id).is_some_and(|p| p.as_str() == recorded);
if !resolves {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Epic,
target: recorded.to_string(),
reason: missing_epic_reason(&holder, recorded),
});
}
}
for project in &epic.projects {
if !targets.project_known(project) {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Project,
target: project.clone(),
reason: missing_project_reason(id, project),
});
}
}
if let Some(milestone) = epic.milestone.as_deref() {
if !targets.milestone_known(milestone) {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Milestone,
target: milestone.to_string(),
reason: missing_milestone_reason(id, milestone),
});
}
}
if let Some(cycle) = epic.cycle.as_deref() {
if !targets.cycle_known(cycle) {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Cycle,
target: cycle.to_string(),
reason: missing_cycle_reason(id, cycle),
});
}
}
out.sort_by(reference_order);
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockedMember {
pub issue: IssueKey,
pub display: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicProgress {
pub done: usize,
pub total: usize,
pub progress: Progress,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WeightedProgress {
pub own_weight: Option<i64>,
pub weighted_children: usize,
pub unweighted_children: usize,
pub done_weight: i64,
pub total_weight: i64,
pub percent: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicRollup {
pub epic_id: EpicId,
pub name: String,
pub progress: EpicProgress,
pub child_count: usize,
pub weighted_progress: WeightedProgress,
pub blocked: usize,
pub blocked_members: Vec<BlockedMember>,
pub missing_references: Vec<EpicReferenceResolution>,
}
impl EpicIndex {
pub fn rollup(
&self,
config: &Config,
reference: &str,
targets: &ReferenceTargets,
graph: &RelationshipGraph<'_>,
) -> Result<EpicRollup, EpicError> {
let epic = self.resolve(reference)?;
let id = epic.id.clone();
let (_, resolution) = crate::workflow::active_resolution(config);
let members = self.bucket(&id).unwrap_or(&[]);
let done_board: HashSet<IssueKey> = members
.iter()
.filter(|key| {
self.status_of(**key)
.and_then(|status| resolution.resolve(status))
.is_some_and(crate::workflow::WorkflowCategory::is_complete)
})
.copied()
.collect();
let done = done_board.len();
let total = members.len();
let blocked_members: Vec<BlockedMember> = self
.blocked_members(&id, graph)
.into_iter()
.filter(|member| !done_board.contains(&member.issue))
.collect();
Ok(EpicRollup {
name: epic.name.clone(),
progress: EpicProgress {
done,
total,
progress: Progress { done, total },
},
child_count: epic.child_count,
weighted_progress: self.weighted_progress(config, &id, &resolution),
blocked: blocked_members.len(),
blocked_members,
missing_references: self.missing_for(&id, targets),
epic_id: id,
})
}
#[must_use]
pub fn blocked_members(
&self,
id: &EpicId,
graph: &RelationshipGraph<'_>,
) -> Vec<BlockedMember> {
let Some(members) = self.buckets.get(id) else {
return Vec::new();
};
let mut out: Vec<BlockedMember> = members
.iter()
.filter_map(|key| {
let state = graph.blocked_state(key.number());
if !state.is_blocked {
return None;
}
Some(BlockedMember {
issue: *key,
display: self
.label(*key)
.map(str::to_string)
.unwrap_or_else(|| key.to_string()),
reason: state.reason,
})
})
.collect();
out.sort_by_key(|member| member.issue);
out
}
fn weighted_progress(
&self,
config: &Config,
id: &EpicId,
resolution: &crate::workflow::CategoryResolution,
) -> WeightedProgress {
let own_weight = config.epics.get(id.as_str()).and_then(|r| r.weight);
let Some(children) = self.children.get(id) else {
return WeightedProgress {
own_weight,
weighted_children: 0,
unweighted_children: 0,
done_weight: 0,
total_weight: 0,
percent: None,
};
};
let mut weighted = 0usize;
let mut unweighted = 0usize;
let mut done_weight = 0i64;
let mut total_weight = 0i64;
for child in children {
let Some(weight) = config.epics.get(child.as_str()).and_then(|r| r.weight) else {
unweighted += 1;
continue;
};
weighted += 1;
total_weight += weight;
if self.child_is_complete(child, resolution) {
done_weight += weight;
}
}
let percent = if total_weight > 0 {
let done = u32::try_from(done_weight.max(0)).unwrap_or(u32::MAX);
let total = u32::try_from(total_weight.max(0)).unwrap_or(u32::MAX);
Some(done.saturating_mul(100) / total.max(1))
} else {
None
};
WeightedProgress {
own_weight,
weighted_children: weighted,
unweighted_children: unweighted,
done_weight,
total_weight,
percent,
}
}
fn child_is_complete(
&self,
child: &EpicId,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
let Some(members) = self.buckets.get(child) else {
return false;
};
if members.is_empty() {
return false;
}
members.iter().all(|key| {
self.status_of(*key)
.and_then(|status| resolution.resolve(status))
.is_some_and(crate::workflow::WorkflowCategory::is_complete)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EpicEventKind {
EpicReferenced,
EpicDereferenced,
}
impl EpicEventKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
EpicEventKind::EpicReferenced => "epic_referenced",
EpicEventKind::EpicDereferenced => "epic_dereferenced",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicEvent {
pub kind: EpicEventKind,
pub epic_id: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicChange {
pub issue: IssueKey,
pub epic_ids: Vec<String>,
pub event: Option<EpicEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkEpicOutcome {
pub selector: u32,
pub outcome: Result<EpicChange, EpicError>,
}
fn find_issue<N: EpicNode>(issues: &[N], selector: u32) -> Result<&N, EpicError> {
issues
.iter()
.find(|issue| issue.number() == selector)
.ok_or(EpicError::UnknownIssue { selector })
}
fn reference_resolved<N: EpicNode>(
issues: &[N],
selector: u32,
id: &EpicId,
) -> Result<EpicChange, EpicError> {
let issue = find_issue(issues, selector)?;
let current = issue.epic_ids();
let key = IssueKey::new(selector);
if current.iter().any(|stored| stored == id.as_str()) {
return Ok(EpicChange {
issue: key,
epic_ids: current.to_vec(),
event: None,
});
}
let mut next = current.to_vec();
next.push(id.to_string());
Ok(EpicChange {
issue: key,
epic_ids: next,
event: Some(EpicEvent {
kind: EpicEventKind::EpicReferenced,
epic_id: id.to_string(),
detail: reference_detail(id.as_str(), true),
}),
})
}
pub fn reference_epic<N: EpicNode>(
config: &Config,
issues: &[N],
issue_selector: u32,
epic_reference: &str,
) -> Result<EpicChange, EpicError> {
let id = resolve_epic_id(&config.epics, epic_reference)?;
reference_resolved(issues, issue_selector, &id)
}
pub fn dereference_epic<N: EpicNode>(
issues: &[N],
issue_selector: u32,
epic_id: &str,
) -> Result<EpicChange, EpicError> {
let issue = find_issue(issues, issue_selector)?;
let current = issue.epic_ids();
let key = IssueKey::new(issue_selector);
if !current.iter().any(|stored| stored == epic_id) {
return Ok(EpicChange {
issue: key,
epic_ids: current.to_vec(),
event: None,
});
}
let next: Vec<String> = current
.iter()
.filter(|stored| stored.as_str() != epic_id)
.cloned()
.collect();
Ok(EpicChange {
issue: key,
epic_ids: next,
event: Some(EpicEvent {
kind: EpicEventKind::EpicDereferenced,
epic_id: epic_id.to_string(),
detail: reference_detail(epic_id, false),
}),
})
}
#[must_use]
pub fn bulk_reference<N: EpicNode>(
config: &Config,
issues: &[N],
epic_reference: &str,
issue_selectors: &[u32],
) -> Vec<BulkEpicOutcome> {
let resolved = resolve_epic_id(&config.epics, epic_reference);
issue_selectors
.iter()
.map(|&selector| {
let outcome = match &resolved {
Ok(id) => reference_resolved(issues, selector, id),
Err(err) => Err(err.clone()),
};
BulkEpicOutcome { selector, outcome }
})
.collect()
}
pub fn rollup<N: EpicNode>(
config: &Config,
issues: &[N],
reference: &str,
targets: &ReferenceTargets,
graph: &RelationshipGraph<'_>,
) -> Result<EpicRollup, EpicError> {
EpicIndex::build(config, issues).rollup(config, reference, targets, graph)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::relationships::{IssueView, RelationKind, Relationship};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Node {
number: u32,
title: String,
status: String,
epics: Vec<String>,
labels: Vec<String>,
}
impl Node {
fn new(number: u32, title: &str) -> Self {
Self {
number,
title: title.to_string(),
status: "todo".to_string(),
epics: Vec::new(),
labels: Vec::new(),
}
}
fn with(mut self, ids: &[&str]) -> Self {
self.epics = ids.iter().map(|id| (*id).to_string()).collect();
self
}
fn in_column(mut self, status: &str) -> Self {
self.status = status.to_string();
self
}
fn labelled(mut self, label: &str) -> Self {
self.labels.push(label.to_string());
self
}
}
impl EpicNode for Node {
fn number(&self) -> u32 {
self.number
}
fn title(&self) -> &str {
&self.title
}
fn status(&self) -> &str {
&self.status
}
fn epic_ids(&self) -> &[String] {
&self.epics
}
}
fn registry(entries: &[(&str, &str)]) -> Config {
let mut config = Config::default();
for (id, name) in entries {
config.epics.insert(
(*id).to_string(),
EpicConfiguration {
name: (*name).to_string(),
..EpicConfiguration::default()
},
);
}
config
}
fn record(name: &str, parent: Option<&str>, weight: Option<i64>) -> EpicConfiguration {
EpicConfiguration {
name: name.to_string(),
parent: parent.map(str::to_string),
weight,
..EpicConfiguration::default()
}
}
fn ids(change: &EpicChange) -> Vec<&str> {
change.epic_ids.iter().map(String::as_str).collect()
}
fn epics(config: &Config) -> &IndexMap<String, EpicConfiguration> {
&config.epics
}
fn targets(projects: &[&str], milestones: &[&str], cycles: &[&str]) -> ReferenceTargets {
ReferenceTargets {
projects: projects.iter().map(|id| (*id).to_string()).collect(),
milestones: milestones.iter().map(|m| (*m).to_string()).collect(),
cycles: cycles.iter().map(|c| (*c).to_string()).collect(),
}
}
fn no_issues() -> Vec<Node> {
Vec::new()
}
struct Blocking {
issues: Vec<Node>,
edges: Vec<Vec<Relationship>>,
}
impl Blocking {
fn new(issues: Vec<Node>, blocks: &[(u32, u32)]) -> Self {
let edges = issues
.iter()
.map(|issue| {
blocks
.iter()
.filter(|(blocker, _)| *blocker == issue.number)
.map(|(blocker, blocked)| {
Relationship::new(*blocker, *blocked, RelationKind::Blocks, "blocked")
})
.collect()
})
.collect();
Self { issues, edges }
}
fn views(&self) -> Vec<IssueView<'_>> {
self.issues
.iter()
.zip(self.edges.iter())
.map(|(issue, edges)| {
IssueView::new(issue.number, &issue.title, &issue.status, edges.as_slice())
})
.collect()
}
}
fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
let mut config = Config::default();
config.kanban.configurations.insert(
"default".to_string(),
crate::config::BoardConfiguration {
columns: columns.iter().map(|c| (*c).to_string()).collect(),
done_column: Some(done_column.to_string()),
category_map: map
.iter()
.map(|(column, key)| ((*column).to_string(), (*key).to_string()))
.collect(),
..Default::default()
},
);
config
}
#[test]
fn t006_create_epic_allocates_a_stable_id_and_the_metadata() {
let mut config = Config::default();
let new = NewEpic {
name: "Harden the sync path".to_string(),
description: Some("Reduce operational risk.".to_string()),
status: Some("active".to_string()),
owner: Some("Alice <alice@example.com>".to_string()),
start_date: Some("2026-09-01".to_string()),
due_date: Some("2026-12-31".to_string()),
weight: Some(5),
..NewEpic::default()
};
let epic = create_epic(&mut config, &new).unwrap();
assert!(epic.id.as_str().starts_with("e-"), "{}", epic.id);
assert_eq!(epic.name, "Harden the sync path");
assert_eq!(epic.weight, Some(5));
assert_eq!(epic.member_count, 0);
assert_eq!(epic.members, [] as [crate::projects::MemberRef; 0]);
assert_eq!(epic.children, [] as [crate::epics::EpicRef; 0]);
assert!(config.epics.contains_key(epic.id.as_str()));
let second = create_epic(&mut config, &new).unwrap_err();
assert_eq!(second.kind(), EpicDiagnosticKind::DuplicateName);
let other = NewEpic {
name: "Other".to_string(),
..NewEpic::default()
};
assert_ne!(create_epic(&mut config, &other).unwrap().id, epic.id);
}
#[test]
fn t006_create_epic_refuses_a_duplicate_name_and_bad_metadata() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let cases = [
(
NewEpic {
name: "platform".to_string(),
..NewEpic::default()
},
"duplicate_name",
),
(
NewEpic {
name: " padded".to_string(),
..NewEpic::default()
},
"duplicate_name",
),
(
NewEpic {
name: String::new(),
..NewEpic::default()
},
"duplicate_name",
),
(
NewEpic {
name: "Dates".to_string(),
start_date: Some("2026-12-31".to_string()),
due_date: Some("2026-01-01".to_string()),
..NewEpic::default()
},
"invalid_dates",
),
(
NewEpic {
name: "Heavy".to_string(),
weight: Some(0),
..NewEpic::default()
},
"invalid_weight",
),
];
for (new, kind) in cases {
let err = create_epic(&mut config, &new).unwrap_err();
assert_eq!(err.kind().as_str(), kind, "{err}");
}
assert_eq!(config.epics.len(), 1);
}
#[test]
fn t006_a_rename_changes_the_label_only() {
let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Child")]);
config
.epics
.insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
let before = config.epics["e-aaaa"].clone();
let edit = EpicEdit {
name: Patch::Set("Platform hardening".to_string()),
..EpicEdit::default()
};
let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
assert_eq!(edited.name, "Platform hardening");
assert_eq!(edited.id.as_str(), "e-aaaa", "a rename never re-keys");
assert_eq!(config.epics["e-aaaa"].name, "Platform hardening");
assert_eq!(config.epics["e-aaaa"].description, before.description);
let index = EpicIndex::build(&config, &issues);
assert_eq!(index.membership("e-aaaa").unwrap().member_count, 1);
assert_eq!(
index.hierarchy("e-bbbb").unwrap().parent.unwrap().name,
"Platform hardening"
);
assert_eq!(
index.resolve("Platform hardening").unwrap().id.as_str(),
"e-aaaa"
);
}
#[test]
fn t006_delete_reports_affected_issues_and_child_epics_and_keeps_them() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config
.epics
.insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
let issues = vec![
Node::new(1, "Member one").with(&["e-aaaa"]),
Node::new(2, "Member two").with(&["e-aaaa", "e-bbbb"]),
];
let deletion = delete_epic(&mut config, "e-aaaa", &issues).unwrap();
assert_eq!(deletion.id.as_str(), "e-aaaa");
assert_eq!(deletion.name, "Platform");
assert_eq!(
deletion
.affected
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(deletion.affected[0].display, "#1 Member one");
assert_eq!(
deletion
.child_epics
.iter()
.map(|e| e.id.as_str())
.collect::<Vec<_>>(),
vec!["e-bbbb"]
);
assert!(!config.epics.contains_key("e-aaaa"));
assert!(config.epics.contains_key("e-bbbb"));
assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
assert_eq!(issues.len(), 2, "no issue is deleted");
let index = EpicIndex::build(&config, &issues);
assert!(
index
.missing()
.iter()
.all(|m| m.target_kind == EpicReferenceKind::Epic)
);
assert_eq!(index.missing().len(), 2);
assert_eq!(
index
.hierarchy("e-bbbb")
.unwrap()
.unresolved
.unwrap()
.recorded_parent,
"e-aaaa"
);
let fresh = create_epic(
&mut config,
&NewEpic {
name: "Fresh".to_string(),
..NewEpic::default()
},
)
.unwrap();
assert_ne!(fresh.id.as_str(), "e-aaaa");
}
#[test]
fn t006_resolution_by_id_and_by_name_agree() {
let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.resolve("e-aaaa").unwrap().name, "Platform");
assert_eq!(index.resolve("Platform").unwrap().id.as_str(), "e-aaaa");
assert_eq!(
index.resolve("Platform").unwrap(),
index.resolve("e-aaaa").unwrap()
);
assert_eq!(index.resolve(" sync ").unwrap().id.as_str(), "e-bbbb");
}
#[test]
fn t006_an_unknown_id_and_an_unknown_name_agree_on_unknown_epic() {
let config = registry(&[("e-aaaa", "Platform")]);
let index = EpicIndex::build(&config, &no_issues());
for reference in ["e-zzzz", "Nowhere"] {
let err = index.resolve(reference).unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::UnknownEpic, "{err}");
assert_eq!(err.kind().as_str(), "unknown_epic");
assert!(err.message().contains(reference));
}
}
#[test]
fn t006_list_epics_is_declaration_ordered_with_derived_counts() {
let mut config = registry(&[("e-bbbb", "Second"), ("e-aaaa", "First")]);
config
.epics
.insert("e-cccc".to_string(), record("Child", Some("e-aaaa"), None));
config.epics.shift_remove("e-aaaa");
config.epics.insert(
"e-aaaa".to_string(),
EpicConfiguration {
name: "First".to_string(),
..Default::default()
},
);
let issues = vec![
Node::new(1, "One").with(&["e-aaaa"]),
Node::new(2, "Two").with(&["e-aaaa"]),
];
let listed = list_epics(&config, &issues);
assert_eq!(
listed.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(),
vec!["e-bbbb", "e-cccc", "e-aaaa"]
);
let first = &listed[2];
assert_eq!(first.member_count, 2);
assert_eq!(first.child_count, 1);
assert_eq!(first.children[0].id.as_str(), "e-cccc");
assert_eq!(first.children[0].display, "Child (e-cccc)");
assert_eq!(
list_epics(&Config::default(), &no_issues()),
[] as [crate::epics::Epic; 0]
);
}
#[test]
fn t006_an_invalid_weight_is_refused_on_edit_and_a_valid_one_is_kept() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let bad = EpicEdit {
weight: Patch::Set(0),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &bad).unwrap_err();
assert_eq!(err.kind().as_str(), "invalid_weight");
assert_eq!(
config.epics["e-aaaa"].weight, None,
"a refusal writes nothing"
);
let good = EpicEdit {
weight: Patch::Set(8),
..EpicEdit::default()
};
assert_eq!(
edit_epic(&mut config, "e-aaaa", &good).unwrap().weight,
Some(8)
);
let cleared = EpicEdit {
weight: Patch::Clear,
..EpicEdit::default()
};
assert_eq!(
edit_epic(&mut config, "e-aaaa", &cleared).unwrap().weight,
None
);
}
#[test]
fn t007_membership_is_ordered_by_issue_number_and_stable_across_rebuilds() {
let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
let issues = vec![
Node::new(9, "Nine").with(&["e-aaaa"]),
Node::new(3, "Three").with(&["e-aaaa", "e-bbbb"]),
Node::new(7, "Seven").with(&["e-aaaa", "e-aaaa"]),
];
let index = EpicIndex::build(&config, &issues);
let membership = index.membership("e-aaaa").unwrap();
assert_eq!(
membership
.members
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![3, 7, 9]
);
assert_eq!(membership.member_count, 3);
assert_eq!(membership.members[0].display, "#3 Three");
assert_eq!(index.membership("e-bbbb").unwrap().member_count, 1);
assert_eq!(
index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
3
);
assert_eq!(index, EpicIndex::build(&config, &issues));
}
#[test]
fn t007_two_epics_sharing_an_issue_both_hold_it_and_an_unreferenced_issue_is_in_none() {
let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
let issues = vec![
Node::new(1, "Shared").with(&["e-aaaa", "e-bbbb"]),
Node::new(2, "Lonely"),
];
let buckets = membership_index(&config, &issues);
assert_eq!(
buckets[&EpicId::new("e-aaaa").unwrap()],
vec![IssueKey::new(1)]
);
assert_eq!(
buckets[&EpicId::new("e-bbbb").unwrap()],
vec![IssueKey::new(1)]
);
assert!(!buckets.values().flatten().any(|key| key.number() == 2));
let config = registry(&[("e-cccc", "Empty")]);
let buckets = membership_index(&config, &no_issues());
assert_eq!(buckets.get(&EpicId::new("e-cccc").unwrap()), None);
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.bucket(&EpicId::new("e-cccc").unwrap()), Some(&[][..]));
assert!(index.bucket(&EpicId::new("e-zzzz").unwrap()).is_none());
}
#[test]
fn t007_a_missing_epic_reference_is_accounted_for_rather_than_dropped() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![Node::new(4, "Dangling").with(&["e-gone", "e-aaaa"])];
let index = EpicIndex::build(&config, &issues);
assert_eq!(
index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
1
);
assert_eq!(index.missing().len(), 1);
let missing = &index.missing()[0];
assert_eq!(missing.target_kind, EpicReferenceKind::Epic);
assert_eq!(missing.target, "e-gone");
assert_eq!(missing.holder, ReferenceHolder::Issue(IssueKey::new(4)));
assert_eq!(missing.holder.to_string(), "issue #4");
assert!(missing.reason.contains("e-gone"));
}
#[test]
fn t007_no_member_list_is_stored_and_a_reference_change_leaves_the_record_alone() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "Member").with(&["e-aaaa"]),
Node::new(2, "Second"),
];
let record_before = config.epics["e-aaaa"].clone();
let change = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
assert_eq!(ids(&change), vec!["e-aaaa"]);
assert_eq!(config.epics["e-aaaa"], record_before);
let serialized = toml::to_string(&config.epics["e-aaaa"]).unwrap();
assert!(!serialized.contains("members"), "{serialized}");
assert!(!serialized.contains("issues"), "{serialized}");
}
#[test]
fn t007_a_task_labelled_issue_is_a_member_like_any_other() {
let task = Node::new(1, "A task").with(&["e-aaaa"]).labelled("task");
let ordinary = Node::new(2, "An issue").with(&["e-aaaa"]);
assert_eq!(task.labels, vec!["task".to_string()]);
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![task, ordinary];
let index = EpicIndex::build(&config, &issues);
let members = index.membership("e-aaaa").unwrap();
assert_eq!(
members
.members
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(index.buckets().len(), 1);
let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
assert!(added.event.is_none(), "it already carries the reference");
assert_eq!(ids(&added), vec!["e-aaaa"]);
let changed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
assert_eq!(ids(&changed), [] as [&str; 0]);
assert_eq!(changed.event.unwrap().kind, EpicEventKind::EpicDereferenced);
}
#[test]
fn t008_a_three_deep_chain_reports_ancestors_children_and_depth() {
let mut config = registry(&[("e-root", "Root")]);
config
.epics
.insert("e-mid".to_string(), record("Mid", Some("e-root"), None));
config
.epics
.insert("e-leaf".to_string(), record("Leaf", Some("e-mid"), None));
let index = EpicIndex::build(&config, &no_issues());
let leaf = index.hierarchy("e-leaf").unwrap();
assert_eq!(leaf.parent.unwrap().id.as_str(), "e-mid");
assert_eq!(leaf.recorded_parent.as_deref(), Some("e-mid"));
assert_eq!(leaf.depth, 2);
assert_eq!(
leaf.ancestors
.iter()
.map(|a| a.id.as_str())
.collect::<Vec<_>>(),
vec!["e-root", "e-mid"],
"ancestors are root-first"
);
assert!(leaf.children.is_empty() && leaf.child_count == 0);
assert!(leaf.unresolved.is_none());
let root = index.hierarchy("e-root").unwrap();
assert!(root.parent.is_none() && root.ancestors.is_empty() && root.depth == 0);
assert_eq!(
root.children
.iter()
.map(|c| c.id.as_str())
.collect::<Vec<_>>(),
vec!["e-mid"]
);
assert_eq!(root.children[0].display, "Mid (e-mid)");
}
#[test]
fn t008_a_self_parent_is_rejected_before_any_write() {
let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Other")]);
let before = config.clone();
let edit = EpicEdit {
parent: Patch::Set("e-aaaa".to_string()),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
assert_eq!(err.kind().as_str(), "self_parent");
assert_eq!(
config, before,
"a refusal leaves the registry byte-unchanged"
);
let by_name = EpicEdit {
parent: Patch::Set("Platform".to_string()),
..EpicEdit::default()
};
assert_eq!(
edit_epic(&mut config, "e-aaaa", &by_name)
.unwrap_err()
.kind(),
EpicDiagnosticKind::SelfParent
);
assert_eq!(config, before);
}
#[test]
fn t008_a_cycle_closing_parent_is_rejected_with_the_path() {
let mut config = registry(&[("e-aaaa", "A")]);
config
.epics
.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
config
.epics
.insert("e-cccc".to_string(), record("C", Some("e-bbbb"), None));
let before = config.epics.clone();
let edit = EpicEdit {
parent: Patch::Set("e-cccc".to_string()),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
let EpicError::Cycle { path, .. } = &err else {
panic!("expected a cycle refusal, got {err}");
};
assert_eq!(
path.iter().map(EpicId::as_str).collect::<Vec<_>>(),
vec!["e-aaaa", "e-cccc", "e-bbbb"]
);
assert!(err.message().contains("e-cccc"));
assert_eq!(config.epics, before, "SC-005: nothing was written");
assert!(config.epics["e-aaaa"].parent.is_none());
}
#[test]
fn t008_a_ten_thousand_node_chain_walks_without_overflow() {
const N: usize = 10_000;
let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
registry.insert("e-00000".to_string(), record("Root", None, None));
for n in 1..N {
let parent = format!("e-{:05}", n - 1);
registry.insert(format!("e-{n:05}"), record("Node", Some(&parent), None));
}
let root = EpicId::new("e-00000").unwrap();
let deepest = format!("e-{:05}", N - 1);
let err = validate_epic_parent(®istry, &root, &deepest).unwrap_err();
let EpicError::Cycle { path, .. } = &err else {
panic!("expected a cycle refusal, got {err}");
};
assert_eq!(path.len(), N);
assert_eq!(path[0].as_str(), "e-00000");
assert_eq!(path[1].as_str(), deepest);
let fresh = EpicId::new("e-new").unwrap();
assert_eq!(
validate_epic_parent(®istry, &fresh, &deepest)
.unwrap()
.as_str(),
deepest
);
let config = Config {
epics: registry,
..Config::default()
};
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.hierarchy(&deepest).unwrap().depth, N - 1);
}
#[test]
fn t008_a_parent_naming_a_deleted_epic_is_unresolved_not_dropped() {
let mut config = registry(&[("e-aaaa", "Gone")]);
config
.epics
.insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
config.epics.shift_remove("e-aaaa");
let index = EpicIndex::build(&config, &no_issues());
let hierarchy = index.hierarchy("e-bbbb").unwrap();
assert!(hierarchy.parent.is_none(), "it does not resolve");
assert_eq!(hierarchy.recorded_parent.as_deref(), Some("e-aaaa"));
let unresolved = hierarchy.unresolved.unwrap();
assert_eq!(unresolved.epic.as_str(), "e-bbbb");
assert_eq!(unresolved.recorded_parent, "e-aaaa");
assert!(unresolved.reason.contains("e-aaaa"));
assert!(unresolved.reason.contains("e-bbbb"));
assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
let edit = EpicEdit {
parent: Patch::Clear,
..EpicEdit::default()
};
let mut config = config;
edit_epic(&mut config, "e-bbbb", &edit).unwrap();
assert!(config.epics["e-bbbb"].parent.is_none());
}
#[test]
fn t008_entering_a_stored_cycle_is_not_closing_one() {
let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
registry.insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
registry.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
registry.insert("e-cccc".to_string(), record("C", Some("e-aaaa"), None));
registry.insert("e-dddd".to_string(), record("D", None, None));
let c = EpicId::new("e-cccc").unwrap();
assert_eq!(
validate_epic_parent(®istry, &c, "e-aaaa")
.unwrap()
.as_str(),
"e-aaaa"
);
let fresh = EpicId::new("e-new1").unwrap();
assert_eq!(
validate_epic_parent(®istry, &fresh, "e-bbbb")
.unwrap()
.as_str(),
"e-bbbb"
);
let defects = validate_epic_chains(®istry);
assert_eq!(defects.len(), 1, "{defects:#?}");
assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
let err = validate_epic_parent(®istry, &c, "e-cccc").unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
let closes = validate_epic_parent(®istry, &EpicId::new("e-dddd").unwrap(), "e-aaaa");
assert_eq!(closes.unwrap().as_str(), "e-aaaa");
}
#[test]
fn t008_a_stored_self_parent_is_reported_once() {
let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
registry.insert("e-aaaa".to_string(), record("A", Some("e-aaaa"), None));
registry.insert("e-bbbb".to_string(), record("B", None, None));
let defects = validate_epic_chains(®istry);
assert_eq!(defects.len(), 1, "one defect, not two: {defects:#?}");
assert_eq!(defects[0].kind(), EpicDiagnosticKind::SelfParent);
assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
}
#[test]
fn t006_the_reference_fields_are_writable_through_the_service() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let created = create_epic(
&mut config,
&NewEpic {
name: "Full".to_string(),
projects: vec!["p-ok1".to_string()],
milestone: Some("2026-Q4".to_string()),
cycle: Some("October 2026".to_string()),
..NewEpic::default()
},
)
.unwrap();
assert_eq!(created.parent, None);
assert_eq!(config.epics[created.id.as_str()].projects, vec!["p-ok1"]);
assert_eq!(
config.epics[created.id.as_str()].milestone.as_deref(),
Some("2026-Q4")
);
let edit = EpicEdit {
projects: Patch::Set(vec!["p-gone".to_string(), "p-gone".to_string()]),
milestone: Patch::Set("Nowhere".to_string()),
cycle: Patch::Clear,
..EpicEdit::default()
};
let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
assert_eq!(edited.projects, vec!["p-gone"]);
assert_eq!(edited.milestone.as_deref(), Some("Nowhere"));
assert_eq!(edited.cycle, None);
let record = &config.epics["e-aaaa"];
assert_eq!(
record.projects,
vec!["p-gone"],
"a duplicate collapses to one"
);
assert_eq!(record.milestone.as_deref(), Some("Nowhere"));
assert_eq!(record.cycle, None);
let found = resolve_references(epics(&config), &no_issues(), &ReferenceTargets::default());
let kinds_of = |epic: &EpicId| -> Vec<EpicReferenceKind> {
found
.iter()
.filter(|f| f.holder == ReferenceHolder::Epic(epic.clone()))
.map(|f| f.target_kind)
.collect()
};
assert_eq!(
kinds_of(&EpicId::new("e-aaaa").unwrap()),
vec![EpicReferenceKind::Project, EpicReferenceKind::Milestone]
);
assert_eq!(
kinds_of(&created.id),
vec![
EpicReferenceKind::Project,
EpicReferenceKind::Milestone,
EpicReferenceKind::Cycle
]
);
}
#[test]
fn t006_a_malformed_project_id_is_refused_before_any_write() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let before = config.clone();
let bad_edit = EpicEdit {
projects: Patch::Set(vec!["Not A Project".to_string()]),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &bad_edit).unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::InvalidReference);
assert_eq!(err.kind().as_str(), "invalid_reference");
assert!(err.message().contains("Not A Project"));
assert_eq!(config, before, "a refusal writes nothing");
let bad_create = create_epic(
&mut config,
&NewEpic {
name: "Bad refs".to_string(),
projects: vec![" p-ok1".to_string()],
..NewEpic::default()
},
);
assert_eq!(
bad_create.unwrap_err().kind(),
EpicDiagnosticKind::InvalidReference
);
assert_eq!(config, before);
}
#[test]
fn t008_a_stored_cycle_is_reported_by_the_chain_validation() {
let mut config = registry(&[("e-aaaa", "A")]);
config
.epics
.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
config
.epics
.insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
let defects = validate_epic_chains(epics(&config));
assert_eq!(defects.len(), 1);
assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.hierarchy("e-aaaa").unwrap().depth, 1);
}
#[test]
fn t009_each_missing_reference_kind_reports_exactly_one_named_entry() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-bbbb".to_string(),
EpicConfiguration {
name: "Broken".to_string(),
parent: Some("e-aaaa".to_string()),
projects: vec!["p-gone".to_string()],
milestone: Some("2026-Q4".to_string()),
cycle: Some("October 2026".to_string()),
..EpicConfiguration::default()
},
);
let issues = vec![
Node::new(1, "Dangling").with(&["e-missing"]),
Node::new(2, "Malformed").with(&["Not An Id"]),
];
let targets = targets(&[], &[], &[]);
let found = resolve_references(epics(&config), &issues, &targets);
assert_eq!(found.len(), 5, "{found:#?}");
for kind in [
EpicReferenceKind::Epic,
EpicReferenceKind::Project,
EpicReferenceKind::Issue,
EpicReferenceKind::Milestone,
EpicReferenceKind::Cycle,
] {
let matching: Vec<&EpicReferenceResolution> =
found.iter().filter(|f| f.target_kind == kind).collect();
assert_eq!(matching.len(), 1, "{kind} must be diagnosed once");
let record = matching[0];
assert!(!record.target.is_empty() || kind == EpicReferenceKind::Issue);
assert_ne!(record.reason, "");
assert!(record.reason.contains(&record.target) || record.target.is_empty());
assert_eq!(
kind.diagnostic_kind(),
format!("missing_{}_reference", kind.as_str())
);
}
let by_kind = |kind: EpicReferenceKind| {
found
.iter()
.find(|f| f.target_kind == kind)
.unwrap()
.clone()
};
let epic_ref = by_kind(EpicReferenceKind::Epic);
assert_eq!(epic_ref.target, "e-missing");
assert_eq!(epic_ref.holder, ReferenceHolder::Issue(IssueKey::new(1)));
assert_eq!(epic_ref.holder.to_string(), "issue #1");
assert_eq!(by_kind(EpicReferenceKind::Project).target, "p-gone");
assert_eq!(by_kind(EpicReferenceKind::Milestone).target, "2026-Q4");
assert_eq!(by_kind(EpicReferenceKind::Cycle).target, "October 2026");
assert_eq!(by_kind(EpicReferenceKind::Issue).target, "Not An Id");
assert_eq!(
by_kind(EpicReferenceKind::Issue).holder,
ReferenceHolder::Issue(IssueKey::new(2))
);
}
#[test]
fn t009_a_clean_snapshot_reports_nothing_and_a_resolvable_one_does_too() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-bbbb".to_string(),
EpicConfiguration {
name: "Complete".to_string(),
parent: Some("e-aaaa".to_string()),
projects: vec!["p-aaaa".to_string()],
milestone: Some("2026-Q4".to_string()),
cycle: Some("October 2026".to_string()),
..EpicConfiguration::default()
},
);
let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
let targets = targets(&["p-aaaa"], &["2026-Q4"], &["October 2026"]);
assert_eq!(
resolve_references(epics(&config), &issues, &targets),
[] as [crate::epics::EpicReferenceResolution; 0]
);
assert_eq!(
resolve_references(
epics(®istry(&[("e-aaaa", "Bare")])),
&no_issues(),
&targets
),
[] as [crate::epics::EpicReferenceResolution; 0]
);
}
#[test]
fn t009_two_runs_over_the_same_input_are_equal_and_ordered() {
let mut config = registry(&[("e-bbbb", "B")]);
config
.epics
.insert("e-aaaa".to_string(), record("A", Some("e-gone"), None));
config
.epics
.insert("e-cccc".to_string(), record("C", Some("e-gone"), None));
let issues = vec![
Node::new(5, "Five").with(&["e-x1"]),
Node::new(2, "Two").with(&["e-x2"]),
];
let first = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
let second = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
assert_eq!(first, second);
assert_eq!(
first
.iter()
.map(|f| (f.holder.to_string(), f.target.clone()))
.collect::<Vec<_>>(),
vec![
("epic `e-aaaa`".to_string(), "e-gone".to_string()),
("epic `e-cccc`".to_string(), "e-gone".to_string()),
("issue #2".to_string(), "e-x2".to_string()),
("issue #5".to_string(), "e-x1".to_string()),
]
);
assert!(
first
.windows(2)
.all(|w| reference_order(&w[0], &w[1]) != std::cmp::Ordering::Greater)
);
}
#[test]
fn t009_targets_come_from_the_planner_lookups_not_a_second_store() {
let mut config = Config::default();
config.cycles.insert(
"October 2026".to_string(),
crate::config::PlanningCycle::default(),
);
let planner = Planner::from_config(config, PathBuf::from("."));
let resolved = ReferenceTargets::from_planner(&planner).unwrap();
assert!(resolved.cycle_known("October 2026"));
assert!(resolved.cycle_known(DEFAULT_CYCLE_NAME));
assert!(!resolved.cycle_known("Nowhere"));
assert!(!resolved.milestone_known("2026-Q4"));
}
#[test]
fn t010_three_members_one_complete_report_done_one_of_three() {
let config = registry(&[("e-aaaa", "Platform")]);
let fixture = Blocking::new(
vec![
Node::new(1, "Complete").in_column("done").with(&["e-aaaa"]),
Node::new(2, "Open").with(&["e-aaaa"]),
Node::new(3, "Also open").with(&["e-aaaa"]),
],
&[],
);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.done, 1);
assert_eq!(figures.progress.total, 3);
assert_eq!(figures.progress.progress, Progress { done: 1, total: 3 });
assert_eq!(figures.child_count, 0);
assert_eq!(figures.blocked, 0);
assert_eq!(figures.weighted_progress.total_weight, 0);
assert_eq!(figures.weighted_progress.percent, None);
}
#[test]
fn t010_an_epic_with_no_members_reports_zeros() {
let config = registry(&[("e-aaaa", "Platform")]);
let fixture = Blocking::new(no_issues(), &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.progress, Progress { done: 0, total: 0 });
assert_eq!(figures.child_count, 0);
assert_eq!(figures.blocked, 0);
assert_eq!(
figures.blocked_members,
[] as [crate::epics::BlockedMember; 0]
);
assert_eq!(
figures.missing_references,
[] as [crate::epics::EpicReferenceResolution; 0]
);
}
#[test]
fn t010_completion_resolves_from_the_configured_column_not_a_literal() {
let mut config = custom_completion(
&["triage", "wip", "shipped"],
"shipped",
&[
("triage", "unstarted"),
("wip", "active"),
("shipped", "completed"),
],
);
config.epics.insert(
"e-aaaa".to_string(),
EpicConfiguration {
name: "Platform".to_string(),
..EpicConfiguration::default()
},
);
let issues = vec![
Node::new(1, "Shipped")
.in_column("shipped")
.with(&["e-aaaa"]),
Node::new(2, "Literal done")
.in_column("done")
.with(&["e-aaaa"]),
];
let fixture = Blocking::new(issues, &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(
figures.progress.done, 1,
"only the configured column counts"
);
assert_eq!(figures.progress.total, 2);
}
#[test]
fn t011_the_known_fixture_counts_every_figure() {
let mut config = registry(&[("e-parent", "Parent"), ("e-child", "Child")]);
config.epics.insert(
"e-parent".to_string(),
EpicConfiguration {
name: "Parent".to_string(),
weight: Some(5),
..EpicConfiguration::default()
},
);
config.epics.insert(
"e-child".to_string(),
EpicConfiguration {
name: "Child".to_string(),
parent: Some("e-parent".to_string()),
weight: Some(3),
projects: vec!["p-gone".to_string()],
..EpicConfiguration::default()
},
);
let issues = vec![
Node::new(1, "Complete")
.in_column("done")
.with(&["e-parent"]),
Node::new(2, "Blocked").with(&["e-parent"]),
Node::new(3, "Open").with(&["e-parent"]),
Node::new(4, "Child member")
.in_column("done")
.with(&["e-child"]),
];
let fixture = Blocking::new(issues, &[(3, 2)]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-parent",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.epic_id.as_str(), "e-parent");
assert_eq!(figures.name, "Parent");
assert_eq!(figures.progress.done, 1);
assert_eq!(figures.progress.total, 3);
assert_eq!(figures.child_count, 1);
assert_eq!(figures.blocked, 1);
assert_eq!(figures.blocked_members.len(), 1);
assert_eq!(figures.blocked_members[0].issue.number(), 2);
assert_ne!(figures.blocked_members[0].reason, "");
let weighted = &figures.weighted_progress;
assert_eq!(weighted.own_weight, Some(5));
assert_eq!(weighted.weighted_children, 1);
assert_eq!(weighted.unweighted_children, 0);
assert_eq!(weighted.total_weight, 3);
assert_eq!(weighted.done_weight, 3);
assert_eq!(weighted.percent, Some(100));
assert_eq!(
figures.missing_references,
[] as [crate::epics::EpicReferenceResolution; 0]
);
let child = rollup(
&config,
&fixture.issues,
"e-child",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(child.progress.progress, Progress { done: 1, total: 1 });
assert_eq!(child.child_count, 0);
assert_eq!(
child
.missing_references
.iter()
.map(|f| f.target_kind)
.collect::<Vec<_>>(),
vec![EpicReferenceKind::Project]
);
assert_eq!(child.weighted_progress.own_weight, Some(3));
assert_eq!(
figures,
rollup(
&config,
&fixture.issues,
"e-parent",
&ReferenceTargets::default(),
&graph
)
.unwrap()
);
}
#[test]
fn t011_weighted_progress_excludes_an_unweighted_child() {
let mut config = registry(&[("e-parent", "Parent")]);
config.epics.insert(
"e-light".to_string(),
record("Light", Some("e-parent"), None),
);
config.epics.insert(
"e-heavy".to_string(),
record("Heavy", Some("e-parent"), Some(10)),
);
let fixture = Blocking::new(vec![], &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-parent",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
let weighted = &figures.weighted_progress;
assert_eq!(
weighted.weighted_children, 1,
"the unweighted child is excluded"
);
assert_eq!(weighted.unweighted_children, 1);
assert_eq!(weighted.total_weight, 10, "not 10 + 0");
assert_eq!(
weighted.done_weight, 0,
"a child with no members is not complete"
);
assert_eq!(weighted.percent, Some(0));
}
#[test]
fn t011_a_done_member_is_never_counted_as_blocked() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "Done but claimed blocked")
.in_column("done")
.with(&["e-aaaa"]),
Node::new(2, "Genuinely blocked").with(&["e-aaaa"]),
Node::new(3, "An open blocker"),
];
let fixture = Blocking::new(issues, &[(1, 1), (3, 1), (3, 2)]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.done, 1);
assert_eq!(figures.blocked, 1);
assert_eq!(figures.blocked_members[0].issue.number(), 2);
}
#[test]
fn t011_the_blocked_read_is_consumed_from_the_relationship_graph() {
let config = registry(&[("e-aaaa", "Platform")]);
let fixture = Blocking::new(
vec![
Node::new(1, "Blocked").with(&["e-aaaa"]),
Node::new(2, "Blocker"),
],
&[(2, 1)],
);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let index = EpicIndex::build(&config, &fixture.issues);
let blocked = index.blocked_members(&EpicId::new("e-aaaa").unwrap(), &graph);
assert_eq!(blocked.len(), 1);
let owner = graph.blocked_state(1);
assert!(owner.is_blocked);
assert_eq!(blocked[0].reason, owner.reason);
}
#[test]
fn t011_missing_references_are_visible_on_the_rollup() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-aaaa".to_string(),
EpicConfiguration {
name: "Platform".to_string(),
parent: Some("e-gone".to_string()),
milestone: Some("Nowhere".to_string()),
..EpicConfiguration::default()
},
);
let fixture = Blocking::new(vec![], &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(
figures
.missing_references
.iter()
.map(|f| f.target_kind)
.collect::<Vec<_>>(),
vec![EpicReferenceKind::Epic, EpicReferenceKind::Milestone]
);
}
#[test]
fn t012_reference_is_idempotent_and_each_accepted_change_has_one_event() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "Member").with(&["e-aaaa"]),
Node::new(2, "Second"),
];
let noop = reference_epic(&config, &issues, 1, "e-aaaa").unwrap();
assert!(
noop.event.is_none(),
"an already-present reference changes nothing"
);
assert_eq!(ids(&noop), vec!["e-aaaa"]);
let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
assert_eq!(ids(&added), vec!["e-aaaa"]);
let event = added.event.unwrap();
assert_eq!(event.kind, EpicEventKind::EpicReferenced);
assert_eq!(event.kind.as_str(), "epic_referenced");
assert_eq!(event.detail, "epic `e-aaaa` referenced");
let removed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
assert_eq!(ids(&removed), [] as [&str; 0]);
let event = removed.event.clone().unwrap();
assert_eq!(event.kind.as_str(), "epic_dereferenced");
assert_eq!(event.detail, "epic `e-aaaa` dereferenced");
let absent = dereference_epic(&issues, 2, "e-aaaa").unwrap();
assert!(absent.event.is_none());
assert_eq!(ids(&absent), [] as [&str; 0]);
let stale = dereference_epic(&issues, 1, "e-gone").unwrap();
assert!(stale.event.is_none());
assert_eq!(ids(&stale), vec!["e-aaaa"]);
assert!(
dereference_epic(&issues, 1, "Platform")
.unwrap()
.event
.is_none()
);
}
#[test]
fn t012_bulk_reference_reports_each_selector_independently() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "One").with(&["e-aaaa"]),
Node::new(2, "Two"),
Node::new(3, "Three"),
];
let outcomes = bulk_reference(&config, &issues, "e-aaaa", &[1, 2, 99, 3]);
assert_eq!(outcomes.len(), 4);
assert_eq!(
outcomes.iter().map(|o| o.selector).collect::<Vec<_>>(),
vec![1, 2, 99, 3]
);
assert!(outcomes[0].outcome.as_ref().unwrap().event.is_none());
assert!(outcomes[1].outcome.as_ref().unwrap().event.is_some());
assert_eq!(
outcomes[2].outcome.as_ref().unwrap_err().kind(),
EpicDiagnosticKind::UnknownIssue
);
assert!(outcomes[3].outcome.as_ref().unwrap().event.is_some());
let all = bulk_reference(&config, &issues, "e-zzzz", &[1, 2]);
assert_eq!(all.len(), 2);
assert!(
all.iter()
.all(|o| o.outcome.as_ref().unwrap_err().kind() == EpicDiagnosticKind::UnknownEpic)
);
}
#[test]
fn t012_reference_epic_refuses_an_unknown_issue() {
let config = registry(&[("e-aaaa", "Platform")]);
let err = reference_epic(&config, &no_issues(), 7, "e-aaaa").unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::UnknownIssue);
assert!(err.message().contains('7'));
}
#[test]
fn t012_every_operation_in_the_contract_is_reachable() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-bbbb".to_string(),
record("Child", Some("e-aaaa"), Some(2)),
);
let issues = vec![Node::new(1, "Member").in_column("done").with(&["e-aaaa"])];
let fixture = Blocking::new(issues.clone(), &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let _ = create_epic(
&mut config,
&NewEpic {
name: "Created".to_string(),
..NewEpic::default()
},
)
.unwrap();
let _ = edit_epic(
&mut config,
"e-bbbb",
&EpicEdit {
status: Patch::Set("active".to_string()),
projects: Patch::Set(vec!["p-ok1".to_string()]),
milestone: Patch::Set("2026-Q4".to_string()),
cycle: Patch::Set("October 2026".to_string()),
..EpicEdit::default()
},
)
.unwrap();
let _ = list_epics(&config, &issues);
let _ = resolve(&config, &issues, "e-aaaa").unwrap();
let _ = membership_index(&config, &issues);
let _ = membership(&config, &issues, "e-aaaa").unwrap();
let _ = hierarchy(&config, &issues, "e-aaaa").unwrap();
let _ = reference_epic(&config, &issues, 1, "e-bbbb").unwrap();
let _ = dereference_epic(&issues, 1, "e-aaaa").unwrap();
let _ = bulk_reference(&config, &issues, "e-aaaa", &[1]);
let _ = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
let _ = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
let deletion = delete_epic(&mut config, "e-bbbb", &issues).unwrap();
assert_eq!(deletion.name, "Child");
assert_eq!(EpicDiagnosticKind::DuplicateName.as_str(), "duplicate_name");
assert_eq!(EpicDiagnosticKind::UnknownEpic.as_str(), "unknown_epic");
assert_eq!(EpicDiagnosticKind::UnknownIssue.as_str(), "unknown_issue");
assert_eq!(EpicDiagnosticKind::InvalidDates.as_str(), "invalid_dates");
assert_eq!(EpicDiagnosticKind::InvalidWeight.as_str(), "invalid_weight");
assert_eq!(
EpicDiagnosticKind::InvalidReference.as_str(),
"invalid_reference"
);
assert_eq!(EpicDiagnosticKind::SelfParent.as_str(), "self_parent");
assert_eq!(EpicDiagnosticKind::Cycle.as_str(), "cycle");
assert_eq!(EPICS_KEY, "epics");
assert_eq!(
epic_display(&EpicId::new("e-aaaa").unwrap(), "Platform"),
"Platform (e-aaaa)"
);
}
#[test]
fn t012_the_module_is_filesystem_free() {
let mut config = Config::default();
let created = create_epic(
&mut config,
&NewEpic {
name: "An epic".to_string(),
weight: Some(1),
..NewEpic::default()
},
)
.unwrap();
let fixture = Blocking::new(
vec![Node::new(1, "Member").with(&[created.id.as_str()])],
&[],
);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
created.id.as_str(),
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.total, 1);
assert_eq!(
resolve_references(
epics(&config),
&fixture.issues,
&ReferenceTargets::default()
),
[] as [crate::epics::EpicReferenceResolution; 0]
);
}
}