1use std::collections::{HashMap, HashSet};
70use std::fmt;
71
72use indexmap::IndexMap;
73
74use crate::config::{Config, EpicConfiguration, EpicId, ProjectId};
75use crate::cycle_store::DEFAULT_CYCLE_NAME;
76use crate::error::{Result, TixError};
77use crate::planning::{Planner, Progress};
78use crate::relationships::RelationshipGraph;
79
80pub use crate::projects::{IssueKey, MemberRef, Patch};
81
82pub const EPICS_KEY: &str = "epics";
87
88pub trait EpicNode {
96 fn number(&self) -> u32;
98 fn title(&self) -> &str;
100 fn status(&self) -> &str;
102 fn epic_ids(&self) -> &[String];
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct EpicRef {
116 pub id: EpicId,
118 pub name: String,
120 pub display: String,
122}
123
124impl EpicRef {
125 #[must_use]
127 pub fn new(id: &EpicId, name: &str) -> Self {
128 Self {
129 id: id.clone(),
130 name: name.to_string(),
131 display: epic_display(id, name),
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Epic {
145 pub id: EpicId,
147 pub name: String,
149 pub description: Option<String>,
151 pub status: Option<String>,
153 pub owner: Option<String>,
155 pub start_date: Option<String>,
157 pub due_date: Option<String>,
159 pub weight: Option<i64>,
161 pub parent: Option<String>,
164 pub projects: Vec<String>,
166 pub milestone: Option<String>,
168 pub cycle: Option<String>,
170 pub members: Vec<MemberRef>,
172 pub member_count: usize,
174 pub children: Vec<EpicRef>,
176 pub child_count: usize,
178}
179
180impl Epic {
181 fn from_configuration(
183 id: EpicId,
184 record: &EpicConfiguration,
185 members: Vec<MemberRef>,
186 children: Vec<EpicRef>,
187 ) -> Self {
188 Self {
189 id,
190 name: record.name.clone(),
191 description: record.description.clone(),
192 status: record.status.clone(),
193 owner: record.owner.clone(),
194 start_date: record.start_date.clone(),
195 due_date: record.due_date.clone(),
196 weight: record.weight,
197 parent: record.parent.clone(),
198 projects: record.projects.clone(),
199 milestone: record.milestone.clone(),
200 cycle: record.cycle.clone(),
201 member_count: members.len(),
202 members,
203 child_count: children.len(),
204 children,
205 }
206 }
207}
208
209#[must_use]
215pub fn epic_display(id: &EpicId, name: &str) -> String {
216 format!("{name} ({id})")
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
224pub enum EpicDiagnosticKind {
225 DuplicateName,
227 UnknownEpic,
229 UnknownIssue,
231 InvalidDates,
233 InvalidWeight,
235 InvalidReference,
237 SelfParent,
239 Cycle,
241}
242
243impl EpicDiagnosticKind {
244 #[must_use]
246 pub fn as_str(self) -> &'static str {
247 match self {
248 EpicDiagnosticKind::DuplicateName => "duplicate_name",
249 EpicDiagnosticKind::UnknownEpic => "unknown_epic",
250 EpicDiagnosticKind::UnknownIssue => "unknown_issue",
251 EpicDiagnosticKind::InvalidDates => "invalid_dates",
252 EpicDiagnosticKind::InvalidWeight => "invalid_weight",
253 EpicDiagnosticKind::InvalidReference => "invalid_reference",
254 EpicDiagnosticKind::SelfParent => "self_parent",
255 EpicDiagnosticKind::Cycle => "cycle",
256 }
257 }
258}
259
260impl fmt::Display for EpicDiagnosticKind {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 f.write_str(self.as_str())
263 }
264}
265
266#[must_use]
268pub fn unknown_epic_reason(reference: &str) -> String {
269 format!("no epic matches `{reference}`; an epic is selected by its stable id or by name")
270}
271
272#[must_use]
274pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
275 format!("issue `{selector}` is not in this store")
276}
277
278#[must_use]
283pub fn invalid_dates_reason(detail: &str) -> String {
284 format!("epic dates are invalid: {detail}")
285}
286
287#[must_use]
289pub fn invalid_weight_reason(value: i64) -> String {
290 format!("epic weight must be a positive integer; got {value}")
291}
292
293#[must_use]
300pub fn invalid_reference_reason(field: &str, value: &str) -> String {
301 format!(
302 "`epics.<id>.{field}` entry `{value}` is not a well-formed id; \
303 an epic references the target by its stable id"
304 )
305}
306
307#[must_use]
309pub fn duplicate_name_reason(name: &str) -> String {
310 if name.trim().is_empty() {
311 "an epic name must not be blank".to_string()
312 } else if name.trim() != name {
313 format!("epic name `{name}` has leading or trailing whitespace")
314 } else {
315 format!("epic name `{name}` is already in use; epic names must be unique")
316 }
317}
318
319#[must_use]
321pub fn self_parent_reason(epic: &EpicId) -> String {
322 format!("epic `{epic}` cannot be its own parent")
323}
324
325#[must_use]
327pub fn cycle_reason(epic: &EpicId, path: &[EpicId]) -> String {
328 let rendered: Vec<&str> = path.iter().map(EpicId::as_str).collect();
329 format!(
330 "parent would close an epic cycle: `{epic}` -> {}",
331 rendered.join(" -> ")
332 )
333}
334
335#[must_use]
337pub fn unresolved_parent_reason(epic: &EpicId, recorded_parent: &str) -> String {
338 format!(
339 "epic `{epic}` records parent `{recorded_parent}`, which no longer exists \
340 (the reference is kept so it can be repaired)"
341 )
342}
343
344#[must_use]
349pub fn reference_detail(epic_id: &str, referenced: bool) -> String {
350 if referenced {
351 format!("epic `{epic_id}` referenced")
352 } else {
353 format!("epic `{epic_id}` dereferenced")
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum EpicError {
363 UnknownEpic {
365 reference: String,
367 },
368 UnknownIssue {
370 selector: u32,
372 },
373 DuplicateName {
375 name: String,
377 },
378 InvalidDates {
380 detail: String,
382 },
383 InvalidWeight {
385 value: i64,
387 },
388 InvalidReference {
390 field: &'static str,
392 value: String,
394 },
395 SelfParent {
397 epic: EpicId,
399 },
400 Cycle {
402 epic: EpicId,
404 path: Vec<EpicId>,
406 },
407}
408
409impl EpicError {
410 #[must_use]
412 pub fn kind(&self) -> EpicDiagnosticKind {
413 match self {
414 EpicError::UnknownEpic { .. } => EpicDiagnosticKind::UnknownEpic,
415 EpicError::UnknownIssue { .. } => EpicDiagnosticKind::UnknownIssue,
416 EpicError::DuplicateName { .. } => EpicDiagnosticKind::DuplicateName,
417 EpicError::InvalidDates { .. } => EpicDiagnosticKind::InvalidDates,
418 EpicError::InvalidWeight { .. } => EpicDiagnosticKind::InvalidWeight,
419 EpicError::InvalidReference { .. } => EpicDiagnosticKind::InvalidReference,
420 EpicError::SelfParent { .. } => EpicDiagnosticKind::SelfParent,
421 EpicError::Cycle { .. } => EpicDiagnosticKind::Cycle,
422 }
423 }
424
425 #[must_use]
427 pub fn message(&self) -> String {
428 match self {
429 EpicError::UnknownEpic { reference } => unknown_epic_reason(reference),
430 EpicError::UnknownIssue { selector } => unknown_issue_reason(selector),
431 EpicError::DuplicateName { name } => duplicate_name_reason(name),
432 EpicError::InvalidDates { detail } => invalid_dates_reason(detail),
433 EpicError::InvalidWeight { value } => invalid_weight_reason(*value),
434 EpicError::InvalidReference { field, value } => invalid_reference_reason(field, value),
435 EpicError::SelfParent { epic } => self_parent_reason(epic),
436 EpicError::Cycle { epic, path } => cycle_reason(epic, path),
437 }
438 }
439}
440
441impl fmt::Display for EpicError {
442 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443 write!(f, "{}: {}", self.kind(), self.message())
444 }
445}
446
447impl std::error::Error for EpicError {}
448
449impl From<EpicError> for TixError {
455 fn from(err: EpicError) -> TixError {
456 TixError::InvalidInput(err.to_string())
457 }
458}
459
460#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct NewEpic {
463 pub name: String,
465 pub description: Option<String>,
467 pub status: Option<String>,
469 pub owner: Option<String>,
471 pub start_date: Option<String>,
473 pub due_date: Option<String>,
475 pub weight: Option<i64>,
477 pub projects: Vec<String>,
480 pub milestone: Option<String>,
482 pub cycle: Option<String>,
484}
485
486#[derive(Debug, Clone, Default, PartialEq, Eq)]
488pub struct EpicEdit {
489 pub name: Patch<String>,
491 pub description: Patch<String>,
493 pub status: Patch<String>,
495 pub owner: Patch<String>,
497 pub start_date: Patch<String>,
499 pub due_date: Patch<String>,
501 pub weight: Patch<i64>,
503 pub parent: Patch<String>,
506 pub projects: Patch<Vec<String>>,
509 pub milestone: Patch<String>,
511 pub cycle: Patch<String>,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct EpicDeletion {
523 pub id: EpicId,
525 pub name: String,
527 pub affected: Vec<MemberRef>,
529 pub child_epics: Vec<EpicRef>,
531}
532
533fn normalize_optional(value: Option<String>) -> Option<String> {
538 value.filter(|v| !v.trim().is_empty())
539}
540
541fn validate_name(
548 registry: &IndexMap<String, EpicConfiguration>,
549 name: &str,
550 exclude: Option<&EpicId>,
551) -> Result<(), EpicError> {
552 if name.trim().is_empty() || name.trim() != name {
553 return Err(EpicError::DuplicateName {
554 name: name.to_string(),
555 });
556 }
557 let normalized = name.to_lowercase();
558 let clash = registry.iter().any(|(raw, record)| {
559 let is_self = exclude.is_some_and(|id| raw.as_str() == id.as_str());
560 !is_self && record.name.to_lowercase() == normalized
561 });
562 if clash {
563 return Err(EpicError::DuplicateName {
564 name: name.to_string(),
565 });
566 }
567 Ok(())
568}
569
570fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), EpicError> {
572 crate::model::validate_issue_dates(start, due).map_err(|err| EpicError::InvalidDates {
573 detail: err.to_string(),
574 })
575}
576
577fn validate_project_ids(list: &[String]) -> Result<Vec<String>, EpicError> {
585 let mut out: Vec<String> = Vec::new();
586 for entry in list {
587 let id = ProjectId::new(entry).map_err(|_| EpicError::InvalidReference {
588 field: "projects",
589 value: entry.clone(),
590 })?;
591 if !out.iter().any(|stored| stored == id.as_str()) {
592 out.push(id.to_string());
593 }
594 }
595 Ok(out)
596}
597
598fn validate_weight(weight: i64) -> Result<(), EpicError> {
600 if weight > 0 {
601 return Ok(());
602 }
603 Err(EpicError::InvalidWeight { value: weight })
604}
605
606pub fn resolve_epic_id(
613 registry: &IndexMap<String, EpicConfiguration>,
614 reference: &str,
615) -> Result<EpicId, EpicError> {
616 if let Ok(id) = EpicId::new(reference) {
617 if registry.contains_key(id.as_str()) {
618 return Ok(id);
619 }
620 }
621 let normalized = reference.trim().to_lowercase();
622 if let Some((raw, _)) = registry
623 .iter()
624 .find(|(_, record)| record.name.to_lowercase() == normalized)
625 {
626 return EpicId::new(raw).map_err(|_| EpicError::UnknownEpic {
627 reference: reference.to_string(),
628 });
629 }
630 Err(EpicError::UnknownEpic {
631 reference: reference.to_string(),
632 })
633}
634
635#[must_use]
648pub(crate) fn dereference_needle(
649 registry: &IndexMap<String, EpicConfiguration>,
650 reference: &str,
651) -> String {
652 resolve_epic_id(registry, reference).map_or_else(|_| reference.to_string(), |id| id.to_string())
653}
654
655fn recorded_children(registry: &IndexMap<String, EpicConfiguration>, id: &EpicId) -> Vec<EpicRef> {
657 registry
658 .iter()
659 .filter(|(_, record)| record.parent.as_deref() == Some(id.as_str()))
660 .filter_map(|(raw, record)| {
661 EpicId::new(raw)
662 .ok()
663 .map(|child| EpicRef::new(&child, &record.name))
664 })
665 .collect()
666}
667
668pub fn create_epic(config: &mut Config, new: &NewEpic) -> Result<Epic, EpicError> {
676 validate_name(&config.epics, &new.name, None)?;
677 validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
678 if let Some(weight) = new.weight {
679 validate_weight(weight)?;
680 }
681 let projects = validate_project_ids(&new.projects)?;
682 let id = config.allocate_epic_id();
683 let record = EpicConfiguration {
684 name: new.name.clone(),
685 description: normalize_optional(new.description.clone()),
686 status: normalize_optional(new.status.clone()),
687 owner: normalize_optional(new.owner.clone()),
688 start_date: new.start_date.clone(),
689 due_date: new.due_date.clone(),
690 weight: new.weight,
691 projects,
692 milestone: normalize_optional(new.milestone.clone()),
693 cycle: normalize_optional(new.cycle.clone()),
694 ..EpicConfiguration::default()
695 };
696 config.epics.insert(id.to_string(), record.clone());
697 Ok(Epic::from_configuration(
698 id,
699 &record,
700 Vec::new(),
701 Vec::new(),
702 ))
703}
704
705pub fn edit_epic(config: &mut Config, reference: &str, edit: &EpicEdit) -> Result<Epic, EpicError> {
713 let id = resolve_epic_id(&config.epics, reference)?;
714 let mut updated =
715 config
716 .epics
717 .get(id.as_str())
718 .cloned()
719 .ok_or_else(|| EpicError::UnknownEpic {
720 reference: reference.to_string(),
721 })?;
722
723 match &edit.name {
725 Patch::Unchanged => {}
726 Patch::Clear => {
727 return Err(EpicError::DuplicateName {
728 name: String::new(),
729 });
730 }
731 Patch::Set(name) => {
732 validate_name(&config.epics, name, Some(&id))?;
733 }
734 }
735 if let Patch::Set(weight) = &edit.weight {
736 validate_weight(*weight)?;
737 }
738 let proposed_parent = match &edit.parent {
739 Patch::Unchanged => None,
740 Patch::Clear => Some(None),
741 Patch::Set(parent) => Some(Some(validate_epic_parent(&config.epics, &id, parent)?)),
742 };
743 let proposed_projects = match &edit.projects {
744 Patch::Unchanged => None,
745 Patch::Clear => Some(Vec::new()),
746 Patch::Set(list) => Some(validate_project_ids(list)?),
747 };
748
749 if let Patch::Set(name) = &edit.name {
751 updated.name.clone_from(name);
752 }
753 apply_optional(&edit.description, &mut updated.description);
754 apply_optional(&edit.status, &mut updated.status);
755 apply_optional(&edit.owner, &mut updated.owner);
756 apply_date(&edit.start_date, &mut updated.start_date);
757 apply_date(&edit.due_date, &mut updated.due_date);
758 if let Patch::Set(weight) = &edit.weight {
759 updated.weight = Some(*weight);
760 }
761 if let Patch::Clear = &edit.weight {
762 updated.weight = None;
763 }
764 if let Some(parent) = proposed_parent {
765 updated.parent = parent.map(|id| id.to_string());
766 }
767 if let Some(projects) = proposed_projects {
768 updated.projects = projects;
769 }
770 apply_optional(&edit.milestone, &mut updated.milestone);
771 apply_optional(&edit.cycle, &mut updated.cycle);
772
773 validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
774
775 if let Some(record) = config.epics.get_mut(id.as_str()) {
776 *record = updated.clone();
777 }
778 Ok(Epic::from_configuration(
779 id,
780 &updated,
781 Vec::new(),
782 Vec::new(),
783 ))
784}
785
786fn apply_optional(patch: &Patch<String>, slot: &mut Option<String>) {
788 match patch {
789 Patch::Unchanged => {}
790 Patch::Clear => *slot = None,
791 Patch::Set(value) => *slot = normalize_optional(Some(value.clone())),
792 }
793}
794
795fn apply_date(patch: &Patch<String>, slot: &mut Option<String>) {
797 match patch {
798 Patch::Unchanged => {}
799 Patch::Clear => *slot = None,
800 Patch::Set(value) => *slot = Some(value.clone()),
801 }
802}
803
804pub fn delete_epic<N: EpicNode>(
812 config: &mut Config,
813 reference: &str,
814 issues: &[N],
815) -> Result<EpicDeletion, EpicError> {
816 let id = resolve_epic_id(&config.epics, reference)?;
817 let record = config
818 .epics
819 .get(id.as_str())
820 .cloned()
821 .ok_or_else(|| EpicError::UnknownEpic {
822 reference: reference.to_string(),
823 })?;
824 let affected = EpicIndex::build(config, issues)
825 .epic(&id)
826 .map(|epic| epic.members.clone())
827 .unwrap_or_default();
828 let child_epics = recorded_children(&config.epics, &id);
829 config.epics.shift_remove(id.as_str());
830 Ok(EpicDeletion {
831 id,
832 name: record.name,
833 affected,
834 child_epics,
835 })
836}
837
838#[derive(Debug, Clone, PartialEq, Eq)]
844pub struct EpicMembership {
845 pub epic_id: EpicId,
847 pub members: Vec<MemberRef>,
849 pub member_count: usize,
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
860pub struct EpicIndex {
861 epics: IndexMap<EpicId, Epic>,
863 buckets: HashMap<EpicId, Vec<IssueKey>>,
865 parents: HashMap<EpicId, EpicId>,
867 children: HashMap<EpicId, Vec<EpicId>>,
869 missing: Vec<EpicReferenceResolution>,
871 labels: HashMap<IssueKey, String>,
873 statuses: HashMap<IssueKey, String>,
875}
876
877impl EpicIndex {
878 pub fn build<N: EpicNode>(config: &Config, issues: &[N]) -> Self {
885 Self::build_from_registry(&config.epics, issues)
886 }
887
888 pub fn build_from_registry<N: EpicNode>(
891 registry: &IndexMap<String, EpicConfiguration>,
892 issues: &[N],
893 ) -> Self {
894 let known: HashSet<String> = registry.keys().cloned().collect();
895
896 let mut labels: HashMap<IssueKey, String> = HashMap::new();
897 let mut statuses: HashMap<IssueKey, String> = HashMap::new();
898 let mut buckets: HashMap<EpicId, Vec<IssueKey>> = HashMap::new();
899 let mut missing: Vec<EpicReferenceResolution> = Vec::new();
900
901 for issue in issues {
902 let key = IssueKey::new(issue.number());
903 labels.insert(
904 key,
905 crate::hierarchy::display_label(issue.number(), issue.title()),
906 );
907 statuses.insert(key, issue.status().to_string());
908
909 let mut seen: HashSet<&str> = HashSet::new();
912 for raw in issue.epic_ids() {
913 if !seen.insert(raw.as_str()) {
914 continue;
915 }
916 let holder = ReferenceHolder::Issue(key);
917 match EpicId::new(raw) {
918 Ok(id) if known.contains(id.as_str()) => {
919 buckets.entry(id).or_default().push(key);
920 }
921 Ok(_) => missing.push(EpicReferenceResolution {
922 holder: holder.clone(),
923 target_kind: EpicReferenceKind::Epic,
924 target: raw.clone(),
925 reason: missing_epic_reason(&holder, raw),
926 }),
927 Err(_) => missing.push(EpicReferenceResolution {
928 holder,
929 target_kind: EpicReferenceKind::Issue,
930 target: raw.clone(),
931 reason: malformed_epic_reason(issue.number(), raw),
932 }),
933 }
934 }
935 }
936
937 for members in buckets.values_mut() {
938 members.sort_unstable();
939 }
940
941 let mut children: HashMap<EpicId, Vec<EpicId>> = HashMap::new();
945 let mut ordered: Vec<(EpicId, EpicConfiguration)> = Vec::new();
946 let parents: HashMap<EpicId, EpicId> = resolved_parents(registry);
947 for (raw, record) in registry {
948 let Ok(id) = EpicId::new(raw) else {
949 continue;
950 };
951 if let Some(parent) = parents.get(&id) {
952 children.entry(parent.clone()).or_default().push(id.clone());
953 }
954 ordered.push((id, record.clone()));
955 }
956
957 let mut epics: IndexMap<EpicId, Epic> = IndexMap::new();
958 for (id, record) in ordered {
959 let members: Vec<MemberRef> = buckets
960 .get(&id)
961 .map(|keys| {
962 keys.iter()
963 .map(|key| MemberRef {
964 issue: *key,
965 display: labels.get(key).cloned().unwrap_or_else(|| key.to_string()),
966 })
967 .collect()
968 })
969 .unwrap_or_default();
970 let kid_refs: Vec<EpicRef> = children
971 .get(&id)
972 .map(|ids| {
973 ids.iter()
974 .filter_map(|child| {
975 registry
976 .get(child.as_str())
977 .map(|record| EpicRef::new(child, &record.name))
978 })
979 .collect()
980 })
981 .unwrap_or_default();
982 epics.insert(
983 id.clone(),
984 Epic::from_configuration(id, &record, members, kid_refs),
985 );
986 }
987
988 missing.sort_by(reference_order);
989
990 Self {
991 epics,
992 buckets,
993 parents,
994 children,
995 missing,
996 labels,
997 statuses,
998 }
999 }
1000
1001 #[must_use]
1003 pub fn buckets(&self) -> &HashMap<EpicId, Vec<IssueKey>> {
1004 &self.buckets
1005 }
1006
1007 #[must_use]
1018 pub fn memberships_by_issue(&self) -> HashMap<u32, Vec<String>> {
1019 let order: HashMap<&str, usize> = self
1020 .epics
1021 .keys()
1022 .enumerate()
1023 .map(|(position, id)| (id.as_str(), position))
1024 .collect();
1025 let mut memberships: HashMap<u32, Vec<String>> = HashMap::new();
1026 for (id, keys) in &self.buckets {
1027 for key in keys {
1028 memberships
1029 .entry(key.number())
1030 .or_default()
1031 .push(id.to_string());
1032 }
1033 }
1034 for ids in memberships.values_mut() {
1037 ids.sort_by_key(|id| order.get(id.as_str()).copied().unwrap_or(usize::MAX));
1038 }
1039 memberships
1040 }
1041
1042 #[must_use]
1048 pub fn bucket(&self, id: &EpicId) -> Option<&[IssueKey]> {
1049 if !self.epics.contains_key(id) {
1050 return None;
1051 }
1052 Some(self.buckets.get(id).map_or(&[][..], Vec::as_slice))
1053 }
1054
1055 #[must_use]
1057 pub fn epics(&self) -> Vec<&Epic> {
1058 self.epics.values().collect()
1059 }
1060
1061 #[must_use]
1063 pub fn epic(&self, id: &EpicId) -> Option<&Epic> {
1064 self.epics.get(id)
1065 }
1066
1067 #[must_use]
1069 pub fn label(&self, key: IssueKey) -> Option<&str> {
1070 self.labels.get(&key).map(String::as_str)
1071 }
1072
1073 #[must_use]
1075 pub fn status_of(&self, key: IssueKey) -> Option<&str> {
1076 self.statuses.get(&key).map(String::as_str)
1077 }
1078
1079 #[must_use]
1084 pub fn missing(&self) -> &[EpicReferenceResolution] {
1085 &self.missing
1086 }
1087
1088 pub fn resolve(&self, reference: &str) -> Result<&Epic, EpicError> {
1090 if let Ok(id) = EpicId::new(reference) {
1091 if let Some(epic) = self.epics.get(&id) {
1092 return Ok(epic);
1093 }
1094 }
1095 let normalized = reference.trim().to_lowercase();
1096 self.epics
1097 .values()
1098 .find(|epic| epic.name.to_lowercase() == normalized)
1099 .ok_or_else(|| EpicError::UnknownEpic {
1100 reference: reference.to_string(),
1101 })
1102 }
1103
1104 pub fn membership(&self, reference: &str) -> Result<EpicMembership, EpicError> {
1106 let epic = self.resolve(reference)?;
1107 Ok(EpicMembership {
1108 epic_id: epic.id.clone(),
1109 members: epic.members.clone(),
1110 member_count: epic.member_count,
1111 })
1112 }
1113
1114 #[must_use]
1116 pub fn into_buckets(self) -> HashMap<EpicId, Vec<IssueKey>> {
1117 self.buckets
1118 }
1119
1120 #[must_use]
1122 pub fn into_missing(self) -> Vec<EpicReferenceResolution> {
1123 self.missing
1124 }
1125
1126 #[must_use]
1128 pub fn into_epics(self) -> Vec<Epic> {
1129 self.epics.into_values().collect()
1130 }
1131}
1132
1133#[must_use]
1137pub fn membership_index<N: EpicNode>(
1138 config: &Config,
1139 issues: &[N],
1140) -> HashMap<EpicId, Vec<IssueKey>> {
1141 EpicIndex::build(config, issues).into_buckets()
1142}
1143
1144#[must_use]
1151pub fn memberships_by_issue<N: EpicNode>(
1152 config: &Config,
1153 issues: &[N],
1154) -> HashMap<u32, Vec<String>> {
1155 EpicIndex::build(config, issues).memberships_by_issue()
1156}
1157
1158pub fn membership<N: EpicNode>(
1160 config: &Config,
1161 issues: &[N],
1162 reference: &str,
1163) -> Result<EpicMembership, EpicError> {
1164 EpicIndex::build(config, issues).membership(reference)
1165}
1166
1167#[must_use]
1172pub fn list_epics<N: EpicNode>(config: &Config, issues: &[N]) -> Vec<Epic> {
1173 EpicIndex::build(config, issues).into_epics()
1174}
1175
1176pub fn resolve<N: EpicNode>(
1178 config: &Config,
1179 issues: &[N],
1180 reference: &str,
1181) -> Result<Epic, EpicError> {
1182 EpicIndex::build(config, issues).resolve(reference).cloned()
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Eq)]
1196pub struct UnresolvedEpicParent {
1197 pub epic: EpicId,
1199 pub recorded_parent: String,
1201 pub reason: String,
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq)]
1207pub struct EpicHierarchy {
1208 pub epic: EpicId,
1210 pub recorded_parent: Option<String>,
1212 pub parent: Option<EpicRef>,
1214 pub children: Vec<EpicRef>,
1216 pub child_count: usize,
1218 pub ancestors: Vec<EpicRef>,
1220 pub depth: usize,
1222 pub unresolved: Option<UnresolvedEpicParent>,
1224}
1225
1226fn epic_cycle_path(
1241 parents: &HashMap<EpicId, EpicId>,
1242 epic: &EpicId,
1243 parent: &EpicId,
1244) -> Option<Vec<EpicId>> {
1245 let mut path = vec![epic.clone()];
1246 let mut visited: HashSet<EpicId> = HashSet::new();
1247 visited.insert(epic.clone());
1248 let mut current = parent.clone();
1249 loop {
1250 if current == *epic {
1251 return Some(path);
1254 }
1255 if !visited.insert(current.clone()) {
1256 return None;
1259 }
1260 path.push(current.clone());
1261 {
1262 let next = parents.get(¤t)?;
1263 current = next.clone()
1264 }
1265 }
1266}
1267
1268fn resolved_parents(registry: &IndexMap<String, EpicConfiguration>) -> HashMap<EpicId, EpicId> {
1276 let known: HashSet<String> = registry.keys().cloned().collect();
1277 let mut parents: HashMap<EpicId, EpicId> = HashMap::new();
1278 for (raw, record) in registry {
1279 let Ok(id) = EpicId::new(raw) else {
1280 continue;
1281 };
1282 if let Some(parent) = record
1283 .parent
1284 .as_deref()
1285 .and_then(|p| EpicId::new(p).ok())
1286 .filter(|p| known.contains(p.as_str()))
1287 {
1288 parents.insert(id, parent);
1289 }
1290 }
1291 parents
1292}
1293
1294pub fn validate_epic_parent(
1307 registry: &IndexMap<String, EpicConfiguration>,
1308 epic: &EpicId,
1309 parent_reference: &str,
1310) -> Result<EpicId, EpicError> {
1311 let parent = resolve_epic_id(registry, parent_reference)?;
1312 if &parent == epic {
1313 return Err(EpicError::SelfParent { epic: epic.clone() });
1314 }
1315 if let Some(path) = epic_cycle_path(&resolved_parents(registry), epic, &parent) {
1316 return Err(EpicError::Cycle {
1317 epic: epic.clone(),
1318 path,
1319 });
1320 }
1321 Ok(parent)
1322}
1323
1324#[must_use]
1330pub fn validate_epic_chains(registry: &IndexMap<String, EpicConfiguration>) -> Vec<EpicError> {
1331 let parents = resolved_parents(registry);
1332 let mut out: Vec<EpicError> = Vec::new();
1333 let mut ids: Vec<EpicId> = registry
1334 .keys()
1335 .filter_map(|raw| EpicId::new(raw).ok())
1336 .collect();
1337 ids.sort();
1338
1339 let mut seen: HashSet<EpicId> = HashSet::new();
1342 for id in &ids {
1343 if parents.get(id) == Some(id) {
1344 seen.insert(id.clone());
1345 out.push(EpicError::SelfParent { epic: id.clone() });
1346 }
1347 }
1348 for id in &ids {
1349 if seen.contains(id) {
1350 continue;
1351 }
1352 let Some(parent) = parents.get(id) else {
1353 continue;
1354 };
1355 if let Some(path) = epic_cycle_path(&parents, id, parent) {
1356 for node in &path {
1357 seen.insert(node.clone());
1358 }
1359 out.push(EpicError::Cycle {
1360 epic: id.clone(),
1361 path,
1362 });
1363 }
1364 }
1365 out.sort_by(|a, b| {
1366 a.kind()
1367 .cmp(&b.kind())
1368 .then_with(|| chain_epic(a).cmp(&chain_epic(b)))
1369 });
1370 out
1371}
1372
1373fn chain_epic(err: &EpicError) -> Option<&EpicId> {
1375 match err {
1376 EpicError::SelfParent { epic } | EpicError::Cycle { epic, .. } => Some(epic),
1377 _ => None,
1378 }
1379}
1380
1381impl EpicIndex {
1382 pub fn hierarchy(&self, reference: &str) -> Result<EpicHierarchy, EpicError> {
1390 let epic = self.resolve(reference)?;
1391 let id = epic.id.clone();
1392
1393 let parent = self.parents.get(&id).and_then(|parent| self.ref_of(parent));
1394 let mut ancestors: Vec<EpicRef> = Vec::new();
1395 let mut visited: HashSet<EpicId> = HashSet::new();
1396 visited.insert(id.clone());
1397 let mut current = self.parents.get(&id).cloned();
1398 while let Some(node) = current {
1399 if !visited.insert(node.clone()) {
1400 break;
1403 }
1404 if let Some(reference) = self.ref_of(&node) {
1405 ancestors.push(reference);
1406 }
1407 current = self.parents.get(&node).cloned();
1408 }
1409 ancestors.reverse();
1410
1411 let children: Vec<EpicRef> = self
1412 .children
1413 .get(&id)
1414 .map(|ids| ids.iter().filter_map(|child| self.ref_of(child)).collect())
1415 .unwrap_or_default();
1416
1417 let unresolved = epic.parent.as_deref().and_then(|recorded| {
1418 let resolves = self
1419 .parents
1420 .get(&id)
1421 .is_some_and(|p| p.as_str() == recorded);
1422 if resolves {
1423 return None;
1424 }
1425 Some(UnresolvedEpicParent {
1426 epic: id.clone(),
1427 recorded_parent: recorded.to_string(),
1428 reason: unresolved_parent_reason(&id, recorded),
1429 })
1430 });
1431
1432 Ok(EpicHierarchy {
1433 epic: id,
1434 recorded_parent: epic.parent.clone(),
1435 parent,
1436 child_count: children.len(),
1437 children,
1438 depth: ancestors.len(),
1439 ancestors,
1440 unresolved,
1441 })
1442 }
1443
1444 fn ref_of(&self, id: &EpicId) -> Option<EpicRef> {
1446 self.epics.get(id).map(|epic| EpicRef::new(id, &epic.name))
1447 }
1448}
1449
1450pub fn hierarchy<N: EpicNode>(
1452 config: &Config,
1453 issues: &[N],
1454 reference: &str,
1455) -> Result<EpicHierarchy, EpicError> {
1456 EpicIndex::build(config, issues).hierarchy(reference)
1457}
1458
1459#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1473pub enum EpicReferenceKind {
1474 Epic,
1476 Project,
1478 Issue,
1480 Milestone,
1482 Cycle,
1484}
1485
1486impl EpicReferenceKind {
1487 #[must_use]
1489 pub fn as_str(self) -> &'static str {
1490 match self {
1491 EpicReferenceKind::Epic => "epic",
1492 EpicReferenceKind::Project => "project",
1493 EpicReferenceKind::Issue => "issue",
1494 EpicReferenceKind::Milestone => "milestone",
1495 EpicReferenceKind::Cycle => "cycle",
1496 }
1497 }
1498
1499 #[must_use]
1502 pub fn diagnostic_kind(self) -> &'static str {
1503 match self {
1504 EpicReferenceKind::Epic => "missing_epic_reference",
1505 EpicReferenceKind::Project => "missing_project_reference",
1506 EpicReferenceKind::Issue => "missing_issue_reference",
1507 EpicReferenceKind::Milestone => "missing_milestone_reference",
1508 EpicReferenceKind::Cycle => "missing_cycle_reference",
1509 }
1510 }
1511}
1512
1513impl fmt::Display for EpicReferenceKind {
1514 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1515 f.write_str(self.as_str())
1516 }
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1526pub enum ReferenceHolder {
1527 Epic(EpicId),
1529 Issue(IssueKey),
1531}
1532
1533impl fmt::Display for ReferenceHolder {
1534 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1535 match self {
1536 ReferenceHolder::Epic(id) => write!(f, "epic `{id}`"),
1537 ReferenceHolder::Issue(key) => write!(f, "issue {key}"),
1538 }
1539 }
1540}
1541
1542#[derive(Debug, Clone, PartialEq, Eq)]
1547pub struct EpicReferenceResolution {
1548 pub holder: ReferenceHolder,
1550 pub target_kind: EpicReferenceKind,
1552 pub target: String,
1554 pub reason: String,
1556}
1557
1558#[must_use]
1560pub fn missing_epic_reason(holder: &ReferenceHolder, target: &str) -> String {
1561 format!(
1562 "{holder} records epic reference `{target}`, which is not in the registry \
1563 (the reference is kept so it can be repaired)"
1564 )
1565}
1566
1567#[must_use]
1569pub fn malformed_epic_reason(issue: u32, raw: &str) -> String {
1570 format!(
1571 "issue #{issue} records epic reference `{raw}`, which is not a well-formed epic id \
1572 (ids are recorded, never names; the entry is kept so it can be repaired)"
1573 )
1574}
1575
1576#[must_use]
1578pub fn missing_project_reason(epic: &EpicId, target: &str) -> String {
1579 format!(
1580 "epic `{epic}` references project `{target}`, which is not in the project registry \
1581 (the reference is kept so it can be repaired)"
1582 )
1583}
1584
1585#[must_use]
1587pub fn missing_milestone_reason(epic: &EpicId, target: &str) -> String {
1588 format!(
1589 "epic `{epic}` references milestone `{target}`, which is not in the milestone registry \
1590 (the reference is kept so it can be repaired)"
1591 )
1592}
1593
1594#[must_use]
1596pub fn missing_cycle_reason(epic: &EpicId, target: &str) -> String {
1597 format!(
1598 "epic `{epic}` references cycle `{target}`, which is not in the cycle registry \
1599 (the reference is kept so it can be repaired)"
1600 )
1601}
1602
1603fn reference_order(a: &EpicReferenceResolution, b: &EpicReferenceResolution) -> std::cmp::Ordering {
1605 a.holder
1606 .cmp(&b.holder)
1607 .then_with(|| a.target_kind.cmp(&b.target_kind))
1608 .then_with(|| a.target.cmp(&b.target))
1609}
1610
1611#[derive(Debug, Clone, Default, PartialEq, Eq)]
1626pub struct ReferenceTargets {
1627 pub projects: HashSet<String>,
1629 pub milestones: HashSet<String>,
1631 pub cycles: HashSet<String>,
1633}
1634
1635impl ReferenceTargets {
1636 pub fn from_planner(planner: &Planner) -> Result<Self> {
1644 let mut cycles: HashSet<String> = planner.cycle_names().into_iter().collect();
1645 if planner.cycle(DEFAULT_CYCLE_NAME).is_some() {
1646 cycles.insert(DEFAULT_CYCLE_NAME.to_string());
1647 }
1648 Ok(Self {
1649 projects: planner.config().projects.keys().cloned().collect(),
1650 milestones: planner.milestone_names()?.into_iter().collect(),
1651 cycles,
1652 })
1653 }
1654
1655 #[must_use]
1657 pub fn project_known(&self, id: &str) -> bool {
1658 self.projects.contains(id)
1659 }
1660
1661 #[must_use]
1663 pub fn milestone_known(&self, name: &str) -> bool {
1664 self.milestones.contains(name)
1665 }
1666
1667 #[must_use]
1669 pub fn cycle_known(&self, name: &str) -> bool {
1670 self.cycles.contains(name)
1671 }
1672}
1673
1674#[must_use]
1686pub fn resolve_references<N: EpicNode>(
1687 registry: &IndexMap<String, EpicConfiguration>,
1688 issues: &[N],
1689 targets: &ReferenceTargets,
1690) -> Vec<EpicReferenceResolution> {
1691 let index = EpicIndex::build_from_registry(registry, issues);
1692 let mut out: Vec<EpicReferenceResolution> = index.missing().to_vec();
1693 for epic in index.epics() {
1694 out.extend(index.missing_for(&epic.id, targets));
1695 }
1696 out.sort_by(reference_order);
1697 out
1698}
1699
1700impl EpicIndex {
1701 #[must_use]
1708 pub fn missing_for(
1709 &self,
1710 id: &EpicId,
1711 targets: &ReferenceTargets,
1712 ) -> Vec<EpicReferenceResolution> {
1713 let Some(epic) = self.epics.get(id) else {
1714 return Vec::new();
1715 };
1716 let holder = ReferenceHolder::Epic(id.clone());
1717 let mut out: Vec<EpicReferenceResolution> = Vec::new();
1718
1719 if let Some(recorded) = epic.parent.as_deref() {
1720 let resolves = self.parents.get(id).is_some_and(|p| p.as_str() == recorded);
1721 if !resolves {
1722 out.push(EpicReferenceResolution {
1723 holder: holder.clone(),
1724 target_kind: EpicReferenceKind::Epic,
1725 target: recorded.to_string(),
1726 reason: missing_epic_reason(&holder, recorded),
1727 });
1728 }
1729 }
1730 for project in &epic.projects {
1731 if !targets.project_known(project) {
1732 out.push(EpicReferenceResolution {
1733 holder: holder.clone(),
1734 target_kind: EpicReferenceKind::Project,
1735 target: project.clone(),
1736 reason: missing_project_reason(id, project),
1737 });
1738 }
1739 }
1740 if let Some(milestone) = epic.milestone.as_deref() {
1741 if !targets.milestone_known(milestone) {
1742 out.push(EpicReferenceResolution {
1743 holder: holder.clone(),
1744 target_kind: EpicReferenceKind::Milestone,
1745 target: milestone.to_string(),
1746 reason: missing_milestone_reason(id, milestone),
1747 });
1748 }
1749 }
1750 if let Some(cycle) = epic.cycle.as_deref() {
1751 if !targets.cycle_known(cycle) {
1752 out.push(EpicReferenceResolution {
1753 holder: holder.clone(),
1754 target_kind: EpicReferenceKind::Cycle,
1755 target: cycle.to_string(),
1756 reason: missing_cycle_reason(id, cycle),
1757 });
1758 }
1759 }
1760 out.sort_by(reference_order);
1761 out
1762 }
1763}
1764
1765#[derive(Debug, Clone, PartialEq, Eq)]
1771pub struct BlockedMember {
1772 pub issue: IssueKey,
1774 pub display: String,
1776 pub reason: String,
1778}
1779
1780#[derive(Debug, Clone, PartialEq, Eq)]
1787pub struct EpicProgress {
1788 pub done: usize,
1790 pub total: usize,
1792 pub progress: Progress,
1794}
1795
1796#[derive(Debug, Clone, PartialEq, Eq)]
1808pub struct WeightedProgress {
1809 pub own_weight: Option<i64>,
1811 pub weighted_children: usize,
1813 pub unweighted_children: usize,
1815 pub done_weight: i64,
1817 pub total_weight: i64,
1819 pub percent: Option<u32>,
1823}
1824
1825#[derive(Debug, Clone, PartialEq, Eq)]
1831pub struct EpicRollup {
1832 pub epic_id: EpicId,
1834 pub name: String,
1836 pub progress: EpicProgress,
1838 pub child_count: usize,
1840 pub weighted_progress: WeightedProgress,
1842 pub blocked: usize,
1844 pub blocked_members: Vec<BlockedMember>,
1846 pub missing_references: Vec<EpicReferenceResolution>,
1848}
1849
1850impl EpicIndex {
1851 pub fn rollup(
1869 &self,
1870 config: &Config,
1871 reference: &str,
1872 targets: &ReferenceTargets,
1873 graph: &RelationshipGraph<'_>,
1874 ) -> Result<EpicRollup, EpicError> {
1875 let epic = self.resolve(reference)?;
1876 let id = epic.id.clone();
1877 let (_, resolution) = crate::workflow::active_resolution(config);
1878
1879 let members = self.bucket(&id).unwrap_or(&[]);
1880 let done_board: HashSet<IssueKey> = members
1881 .iter()
1882 .filter(|key| {
1883 self.status_of(**key)
1884 .and_then(|status| resolution.resolve(status))
1885 .is_some_and(crate::workflow::WorkflowCategory::is_complete)
1886 })
1887 .copied()
1888 .collect();
1889 let done = done_board.len();
1890 let total = members.len();
1891
1892 let blocked_members: Vec<BlockedMember> = self
1893 .blocked_members(&id, graph)
1894 .into_iter()
1895 .filter(|member| !done_board.contains(&member.issue))
1896 .collect();
1897
1898 Ok(EpicRollup {
1899 name: epic.name.clone(),
1900 progress: EpicProgress {
1901 done,
1902 total,
1903 progress: Progress { done, total },
1904 },
1905 child_count: epic.child_count,
1906 weighted_progress: self.weighted_progress(config, &id, &resolution),
1907 blocked: blocked_members.len(),
1908 blocked_members,
1909 missing_references: self.missing_for(&id, targets),
1910 epic_id: id,
1911 })
1912 }
1913
1914 #[must_use]
1920 pub fn blocked_members(
1921 &self,
1922 id: &EpicId,
1923 graph: &RelationshipGraph<'_>,
1924 ) -> Vec<BlockedMember> {
1925 let Some(members) = self.buckets.get(id) else {
1926 return Vec::new();
1927 };
1928 let mut out: Vec<BlockedMember> = members
1929 .iter()
1930 .filter_map(|key| {
1931 let state = graph.blocked_state(key.number());
1932 if !state.is_blocked {
1933 return None;
1934 }
1935 Some(BlockedMember {
1936 issue: *key,
1937 display: self
1938 .label(*key)
1939 .map(str::to_string)
1940 .unwrap_or_else(|| key.to_string()),
1941 reason: state.reason,
1942 })
1943 })
1944 .collect();
1945 out.sort_by_key(|member| member.issue);
1946 out
1947 }
1948
1949 fn weighted_progress(
1955 &self,
1956 config: &Config,
1957 id: &EpicId,
1958 resolution: &crate::workflow::CategoryResolution,
1959 ) -> WeightedProgress {
1960 let own_weight = config.epics.get(id.as_str()).and_then(|r| r.weight);
1961 let Some(children) = self.children.get(id) else {
1962 return WeightedProgress {
1963 own_weight,
1964 weighted_children: 0,
1965 unweighted_children: 0,
1966 done_weight: 0,
1967 total_weight: 0,
1968 percent: None,
1969 };
1970 };
1971 let mut weighted = 0usize;
1972 let mut unweighted = 0usize;
1973 let mut done_weight = 0i64;
1974 let mut total_weight = 0i64;
1975 for child in children {
1976 let Some(weight) = config.epics.get(child.as_str()).and_then(|r| r.weight) else {
1977 unweighted += 1;
1978 continue;
1979 };
1980 weighted += 1;
1981 total_weight += weight;
1982 if self.child_is_complete(child, resolution) {
1983 done_weight += weight;
1984 }
1985 }
1986 let percent = if total_weight > 0 {
1987 let done = u32::try_from(done_weight.max(0)).unwrap_or(u32::MAX);
1988 let total = u32::try_from(total_weight.max(0)).unwrap_or(u32::MAX);
1989 Some(done.saturating_mul(100) / total.max(1))
1990 } else {
1991 None
1992 };
1993 WeightedProgress {
1994 own_weight,
1995 weighted_children: weighted,
1996 unweighted_children: unweighted,
1997 done_weight,
1998 total_weight,
1999 percent,
2000 }
2001 }
2002
2003 fn child_is_complete(
2005 &self,
2006 child: &EpicId,
2007 resolution: &crate::workflow::CategoryResolution,
2008 ) -> bool {
2009 let Some(members) = self.buckets.get(child) else {
2010 return false;
2011 };
2012 if members.is_empty() {
2013 return false;
2014 }
2015 members.iter().all(|key| {
2016 self.status_of(*key)
2017 .and_then(|status| resolution.resolve(status))
2018 .is_some_and(crate::workflow::WorkflowCategory::is_complete)
2019 })
2020 }
2021}
2022
2023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2033pub enum EpicEventKind {
2034 EpicReferenced,
2036 EpicDereferenced,
2038}
2039
2040impl EpicEventKind {
2041 #[must_use]
2043 pub fn as_str(self) -> &'static str {
2044 match self {
2045 EpicEventKind::EpicReferenced => "epic_referenced",
2046 EpicEventKind::EpicDereferenced => "epic_dereferenced",
2047 }
2048 }
2049}
2050
2051#[derive(Debug, Clone, PartialEq, Eq)]
2053pub struct EpicEvent {
2054 pub kind: EpicEventKind,
2056 pub epic_id: String,
2059 pub detail: String,
2061}
2062
2063#[derive(Debug, Clone, PartialEq, Eq)]
2069pub struct EpicChange {
2070 pub issue: IssueKey,
2072 pub epic_ids: Vec<String>,
2074 pub event: Option<EpicEvent>,
2080}
2081
2082#[derive(Debug, Clone, PartialEq, Eq)]
2088pub struct BulkEpicOutcome {
2089 pub selector: u32,
2091 pub outcome: Result<EpicChange, EpicError>,
2093}
2094
2095fn find_issue<N: EpicNode>(issues: &[N], selector: u32) -> Result<&N, EpicError> {
2097 issues
2098 .iter()
2099 .find(|issue| issue.number() == selector)
2100 .ok_or(EpicError::UnknownIssue { selector })
2101}
2102
2103fn reference_resolved<N: EpicNode>(
2105 issues: &[N],
2106 selector: u32,
2107 id: &EpicId,
2108) -> Result<EpicChange, EpicError> {
2109 let issue = find_issue(issues, selector)?;
2110 let current = issue.epic_ids();
2111 let key = IssueKey::new(selector);
2112 if current.iter().any(|stored| stored == id.as_str()) {
2113 return Ok(EpicChange {
2114 issue: key,
2115 epic_ids: current.to_vec(),
2116 event: None,
2117 });
2118 }
2119 let mut next = current.to_vec();
2120 next.push(id.to_string());
2121 Ok(EpicChange {
2122 issue: key,
2123 epic_ids: next,
2124 event: Some(EpicEvent {
2125 kind: EpicEventKind::EpicReferenced,
2126 epic_id: id.to_string(),
2127 detail: reference_detail(id.as_str(), true),
2128 }),
2129 })
2130}
2131
2132pub fn reference_epic<N: EpicNode>(
2138 config: &Config,
2139 issues: &[N],
2140 issue_selector: u32,
2141 epic_reference: &str,
2142) -> Result<EpicChange, EpicError> {
2143 let id = resolve_epic_id(&config.epics, epic_reference)?;
2144 reference_resolved(issues, issue_selector, &id)
2145}
2146
2147pub fn dereference_epic<N: EpicNode>(
2161 issues: &[N],
2162 issue_selector: u32,
2163 epic_id: &str,
2164) -> Result<EpicChange, EpicError> {
2165 let issue = find_issue(issues, issue_selector)?;
2166 let current = issue.epic_ids();
2167 let key = IssueKey::new(issue_selector);
2168 if !current.iter().any(|stored| stored == epic_id) {
2169 return Ok(EpicChange {
2170 issue: key,
2171 epic_ids: current.to_vec(),
2172 event: None,
2173 });
2174 }
2175 let next: Vec<String> = current
2176 .iter()
2177 .filter(|stored| stored.as_str() != epic_id)
2178 .cloned()
2179 .collect();
2180 Ok(EpicChange {
2181 issue: key,
2182 epic_ids: next,
2183 event: Some(EpicEvent {
2184 kind: EpicEventKind::EpicDereferenced,
2185 epic_id: epic_id.to_string(),
2186 detail: reference_detail(epic_id, false),
2187 }),
2188 })
2189}
2190
2191#[must_use]
2197pub fn bulk_reference<N: EpicNode>(
2198 config: &Config,
2199 issues: &[N],
2200 epic_reference: &str,
2201 issue_selectors: &[u32],
2202) -> Vec<BulkEpicOutcome> {
2203 let resolved = resolve_epic_id(&config.epics, epic_reference);
2204 issue_selectors
2205 .iter()
2206 .map(|&selector| {
2207 let outcome = match &resolved {
2208 Ok(id) => reference_resolved(issues, selector, id),
2209 Err(err) => Err(err.clone()),
2210 };
2211 BulkEpicOutcome { selector, outcome }
2212 })
2213 .collect()
2214}
2215
2216pub fn rollup<N: EpicNode>(
2218 config: &Config,
2219 issues: &[N],
2220 reference: &str,
2221 targets: &ReferenceTargets,
2222 graph: &RelationshipGraph<'_>,
2223) -> Result<EpicRollup, EpicError> {
2224 EpicIndex::build(config, issues).rollup(config, reference, targets, graph)
2225}
2226
2227#[cfg(test)]
2228mod tests {
2229 use super::*;
2230 use crate::relationships::{IssueView, RelationKind, Relationship};
2231 use std::path::PathBuf;
2232
2233 #[derive(Debug, Clone, PartialEq, Eq)]
2241 struct Node {
2242 number: u32,
2243 title: String,
2244 status: String,
2245 epics: Vec<String>,
2246 labels: Vec<String>,
2247 }
2248
2249 impl Node {
2250 fn new(number: u32, title: &str) -> Self {
2251 Self {
2252 number,
2253 title: title.to_string(),
2254 status: "todo".to_string(),
2255 epics: Vec::new(),
2256 labels: Vec::new(),
2257 }
2258 }
2259
2260 fn with(mut self, ids: &[&str]) -> Self {
2261 self.epics = ids.iter().map(|id| (*id).to_string()).collect();
2262 self
2263 }
2264
2265 fn in_column(mut self, status: &str) -> Self {
2266 self.status = status.to_string();
2267 self
2268 }
2269
2270 fn labelled(mut self, label: &str) -> Self {
2271 self.labels.push(label.to_string());
2272 self
2273 }
2274 }
2275
2276 impl EpicNode for Node {
2277 fn number(&self) -> u32 {
2278 self.number
2279 }
2280 fn title(&self) -> &str {
2281 &self.title
2282 }
2283 fn status(&self) -> &str {
2284 &self.status
2285 }
2286 fn epic_ids(&self) -> &[String] {
2287 &self.epics
2288 }
2289 }
2290
2291 fn registry(entries: &[(&str, &str)]) -> Config {
2293 let mut config = Config::default();
2294 for (id, name) in entries {
2295 config.epics.insert(
2296 (*id).to_string(),
2297 EpicConfiguration {
2298 name: (*name).to_string(),
2299 ..EpicConfiguration::default()
2300 },
2301 );
2302 }
2303 config
2304 }
2305
2306 fn record(name: &str, parent: Option<&str>, weight: Option<i64>) -> EpicConfiguration {
2308 EpicConfiguration {
2309 name: name.to_string(),
2310 parent: parent.map(str::to_string),
2311 weight,
2312 ..EpicConfiguration::default()
2313 }
2314 }
2315
2316 fn ids(change: &EpicChange) -> Vec<&str> {
2317 change.epic_ids.iter().map(String::as_str).collect()
2318 }
2319
2320 fn epics(config: &Config) -> &IndexMap<String, EpicConfiguration> {
2322 &config.epics
2323 }
2324
2325 fn targets(projects: &[&str], milestones: &[&str], cycles: &[&str]) -> ReferenceTargets {
2327 ReferenceTargets {
2328 projects: projects.iter().map(|id| (*id).to_string()).collect(),
2329 milestones: milestones.iter().map(|m| (*m).to_string()).collect(),
2330 cycles: cycles.iter().map(|c| (*c).to_string()).collect(),
2331 }
2332 }
2333
2334 fn no_issues() -> Vec<Node> {
2336 Vec::new()
2337 }
2338
2339 struct Blocking {
2346 issues: Vec<Node>,
2347 edges: Vec<Vec<Relationship>>,
2348 }
2349
2350 impl Blocking {
2351 fn new(issues: Vec<Node>, blocks: &[(u32, u32)]) -> Self {
2353 let edges = issues
2354 .iter()
2355 .map(|issue| {
2356 blocks
2357 .iter()
2358 .filter(|(blocker, _)| *blocker == issue.number)
2359 .map(|(blocker, blocked)| {
2360 Relationship::new(*blocker, *blocked, RelationKind::Blocks, "blocked")
2361 })
2362 .collect()
2363 })
2364 .collect();
2365 Self { issues, edges }
2366 }
2367
2368 fn views(&self) -> Vec<IssueView<'_>> {
2370 self.issues
2371 .iter()
2372 .zip(self.edges.iter())
2373 .map(|(issue, edges)| {
2374 IssueView::new(issue.number, &issue.title, &issue.status, edges.as_slice())
2375 })
2376 .collect()
2377 }
2378 }
2379
2380 fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
2383 let mut config = Config::default();
2384 config.kanban.configurations.insert(
2385 "default".to_string(),
2386 crate::config::BoardConfiguration {
2387 columns: columns.iter().map(|c| (*c).to_string()).collect(),
2388 done_column: Some(done_column.to_string()),
2389 category_map: map
2390 .iter()
2391 .map(|(column, key)| ((*column).to_string(), (*key).to_string()))
2392 .collect(),
2393 ..Default::default()
2394 },
2395 );
2396 config
2397 }
2398
2399 #[test]
2402 fn t006_create_epic_allocates_a_stable_id_and_the_metadata() {
2403 let mut config = Config::default();
2404 let new = NewEpic {
2405 name: "Harden the sync path".to_string(),
2406 description: Some("Reduce operational risk.".to_string()),
2407 status: Some("active".to_string()),
2408 owner: Some("Alice <alice@example.com>".to_string()),
2409 start_date: Some("2026-09-01".to_string()),
2410 due_date: Some("2026-12-31".to_string()),
2411 weight: Some(5),
2412 ..NewEpic::default()
2413 };
2414 let epic = create_epic(&mut config, &new).unwrap();
2415 assert!(epic.id.as_str().starts_with("e-"), "{}", epic.id);
2416 assert_eq!(epic.name, "Harden the sync path");
2417 assert_eq!(epic.weight, Some(5));
2418 assert_eq!(epic.member_count, 0);
2419 assert_eq!(epic.members, [] as [crate::projects::MemberRef; 0]);
2420 assert_eq!(epic.children, [] as [crate::epics::EpicRef; 0]);
2421 assert!(config.epics.contains_key(epic.id.as_str()));
2423 let second = create_epic(&mut config, &new).unwrap_err();
2425 assert_eq!(second.kind(), EpicDiagnosticKind::DuplicateName);
2426 let other = NewEpic {
2427 name: "Other".to_string(),
2428 ..NewEpic::default()
2429 };
2430 assert_ne!(create_epic(&mut config, &other).unwrap().id, epic.id);
2431 }
2432
2433 #[test]
2434 fn t006_create_epic_refuses_a_duplicate_name_and_bad_metadata() {
2435 let mut config = registry(&[("e-aaaa", "Platform")]);
2436 let cases = [
2437 (
2438 NewEpic {
2439 name: "platform".to_string(),
2440 ..NewEpic::default()
2441 },
2442 "duplicate_name",
2443 ),
2444 (
2445 NewEpic {
2446 name: " padded".to_string(),
2447 ..NewEpic::default()
2448 },
2449 "duplicate_name",
2450 ),
2451 (
2452 NewEpic {
2453 name: String::new(),
2454 ..NewEpic::default()
2455 },
2456 "duplicate_name",
2457 ),
2458 (
2459 NewEpic {
2460 name: "Dates".to_string(),
2461 start_date: Some("2026-12-31".to_string()),
2462 due_date: Some("2026-01-01".to_string()),
2463 ..NewEpic::default()
2464 },
2465 "invalid_dates",
2466 ),
2467 (
2468 NewEpic {
2469 name: "Heavy".to_string(),
2470 weight: Some(0),
2471 ..NewEpic::default()
2472 },
2473 "invalid_weight",
2474 ),
2475 ];
2476 for (new, kind) in cases {
2477 let err = create_epic(&mut config, &new).unwrap_err();
2478 assert_eq!(err.kind().as_str(), kind, "{err}");
2479 }
2480 assert_eq!(config.epics.len(), 1);
2482 }
2483
2484 #[test]
2485 fn t006_a_rename_changes_the_label_only() {
2486 let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Child")]);
2487 config
2488 .epics
2489 .insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
2490 let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
2491 let before = config.epics["e-aaaa"].clone();
2492
2493 let edit = EpicEdit {
2494 name: Patch::Set("Platform hardening".to_string()),
2495 ..EpicEdit::default()
2496 };
2497 let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
2498 assert_eq!(edited.name, "Platform hardening");
2499 assert_eq!(edited.id.as_str(), "e-aaaa", "a rename never re-keys");
2500
2501 assert_eq!(config.epics["e-aaaa"].name, "Platform hardening");
2503 assert_eq!(config.epics["e-aaaa"].description, before.description);
2504 let index = EpicIndex::build(&config, &issues);
2505 assert_eq!(index.membership("e-aaaa").unwrap().member_count, 1);
2506 assert_eq!(
2507 index.hierarchy("e-bbbb").unwrap().parent.unwrap().name,
2508 "Platform hardening"
2509 );
2510 assert_eq!(
2511 index.resolve("Platform hardening").unwrap().id.as_str(),
2512 "e-aaaa"
2513 );
2514 }
2515
2516 #[test]
2517 fn t006_delete_reports_affected_issues_and_child_epics_and_keeps_them() {
2518 let mut config = registry(&[("e-aaaa", "Platform")]);
2519 config
2520 .epics
2521 .insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
2522 let issues = vec![
2523 Node::new(1, "Member one").with(&["e-aaaa"]),
2524 Node::new(2, "Member two").with(&["e-aaaa", "e-bbbb"]),
2525 ];
2526
2527 let deletion = delete_epic(&mut config, "e-aaaa", &issues).unwrap();
2528 assert_eq!(deletion.id.as_str(), "e-aaaa");
2529 assert_eq!(deletion.name, "Platform");
2530 assert_eq!(
2531 deletion
2532 .affected
2533 .iter()
2534 .map(|m| m.issue.number())
2535 .collect::<Vec<_>>(),
2536 vec![1, 2]
2537 );
2538 assert_eq!(deletion.affected[0].display, "#1 Member one");
2539 assert_eq!(
2540 deletion
2541 .child_epics
2542 .iter()
2543 .map(|e| e.id.as_str())
2544 .collect::<Vec<_>>(),
2545 vec!["e-bbbb"]
2546 );
2547 assert!(!config.epics.contains_key("e-aaaa"));
2549 assert!(config.epics.contains_key("e-bbbb"));
2550 assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
2551 assert_eq!(issues.len(), 2, "no issue is deleted");
2552 let index = EpicIndex::build(&config, &issues);
2554 assert!(
2555 index
2556 .missing()
2557 .iter()
2558 .all(|m| m.target_kind == EpicReferenceKind::Epic)
2559 );
2560 assert_eq!(index.missing().len(), 2);
2561 assert_eq!(
2562 index
2563 .hierarchy("e-bbbb")
2564 .unwrap()
2565 .unresolved
2566 .unwrap()
2567 .recorded_parent,
2568 "e-aaaa"
2569 );
2570 let fresh = create_epic(
2572 &mut config,
2573 &NewEpic {
2574 name: "Fresh".to_string(),
2575 ..NewEpic::default()
2576 },
2577 )
2578 .unwrap();
2579 assert_ne!(fresh.id.as_str(), "e-aaaa");
2580 }
2581
2582 #[test]
2583 fn t006_resolution_by_id_and_by_name_agree() {
2584 let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
2585 let index = EpicIndex::build(&config, &no_issues());
2586 assert_eq!(index.resolve("e-aaaa").unwrap().name, "Platform");
2587 assert_eq!(index.resolve("Platform").unwrap().id.as_str(), "e-aaaa");
2588 assert_eq!(
2589 index.resolve("Platform").unwrap(),
2590 index.resolve("e-aaaa").unwrap()
2591 );
2592 assert_eq!(index.resolve(" sync ").unwrap().id.as_str(), "e-bbbb");
2594 }
2595
2596 #[test]
2597 fn t006_an_unknown_id_and_an_unknown_name_agree_on_unknown_epic() {
2598 let config = registry(&[("e-aaaa", "Platform")]);
2599 let index = EpicIndex::build(&config, &no_issues());
2600 for reference in ["e-zzzz", "Nowhere"] {
2601 let err = index.resolve(reference).unwrap_err();
2602 assert_eq!(err.kind(), EpicDiagnosticKind::UnknownEpic, "{err}");
2603 assert_eq!(err.kind().as_str(), "unknown_epic");
2604 assert!(err.message().contains(reference));
2605 }
2606 }
2607
2608 #[test]
2609 fn t006_list_epics_is_declaration_ordered_with_derived_counts() {
2610 let mut config = registry(&[("e-bbbb", "Second"), ("e-aaaa", "First")]);
2611 config
2612 .epics
2613 .insert("e-cccc".to_string(), record("Child", Some("e-aaaa"), None));
2614 config.epics.shift_remove("e-aaaa");
2616 config.epics.insert(
2617 "e-aaaa".to_string(),
2618 EpicConfiguration {
2619 name: "First".to_string(),
2620 ..Default::default()
2621 },
2622 );
2623 let issues = vec![
2624 Node::new(1, "One").with(&["e-aaaa"]),
2625 Node::new(2, "Two").with(&["e-aaaa"]),
2626 ];
2627 let listed = list_epics(&config, &issues);
2628 assert_eq!(
2629 listed.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(),
2630 vec!["e-bbbb", "e-cccc", "e-aaaa"]
2631 );
2632 let first = &listed[2];
2633 assert_eq!(first.member_count, 2);
2634 assert_eq!(first.child_count, 1);
2635 assert_eq!(first.children[0].id.as_str(), "e-cccc");
2636 assert_eq!(first.children[0].display, "Child (e-cccc)");
2637 assert_eq!(
2639 list_epics(&Config::default(), &no_issues()),
2640 [] as [crate::epics::Epic; 0]
2641 );
2642 }
2643
2644 #[test]
2645 fn t006_an_invalid_weight_is_refused_on_edit_and_a_valid_one_is_kept() {
2646 let mut config = registry(&[("e-aaaa", "Platform")]);
2647 let bad = EpicEdit {
2648 weight: Patch::Set(0),
2649 ..EpicEdit::default()
2650 };
2651 let err = edit_epic(&mut config, "e-aaaa", &bad).unwrap_err();
2652 assert_eq!(err.kind().as_str(), "invalid_weight");
2653 assert_eq!(
2654 config.epics["e-aaaa"].weight, None,
2655 "a refusal writes nothing"
2656 );
2657 let good = EpicEdit {
2658 weight: Patch::Set(8),
2659 ..EpicEdit::default()
2660 };
2661 assert_eq!(
2662 edit_epic(&mut config, "e-aaaa", &good).unwrap().weight,
2663 Some(8)
2664 );
2665 let cleared = EpicEdit {
2666 weight: Patch::Clear,
2667 ..EpicEdit::default()
2668 };
2669 assert_eq!(
2670 edit_epic(&mut config, "e-aaaa", &cleared).unwrap().weight,
2671 None
2672 );
2673 }
2674
2675 #[test]
2678 fn t007_membership_is_ordered_by_issue_number_and_stable_across_rebuilds() {
2679 let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
2680 let issues = vec![
2681 Node::new(9, "Nine").with(&["e-aaaa"]),
2682 Node::new(3, "Three").with(&["e-aaaa", "e-bbbb"]),
2683 Node::new(7, "Seven").with(&["e-aaaa", "e-aaaa"]),
2684 ];
2685 let index = EpicIndex::build(&config, &issues);
2686 let membership = index.membership("e-aaaa").unwrap();
2687 assert_eq!(
2688 membership
2689 .members
2690 .iter()
2691 .map(|m| m.issue.number())
2692 .collect::<Vec<_>>(),
2693 vec![3, 7, 9]
2694 );
2695 assert_eq!(membership.member_count, 3);
2696 assert_eq!(membership.members[0].display, "#3 Three");
2697 assert_eq!(index.membership("e-bbbb").unwrap().member_count, 1);
2698 assert_eq!(
2700 index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
2701 3
2702 );
2703 assert_eq!(index, EpicIndex::build(&config, &issues));
2705 }
2706
2707 #[test]
2708 fn t007_two_epics_sharing_an_issue_both_hold_it_and_an_unreferenced_issue_is_in_none() {
2709 let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
2710 let issues = vec![
2711 Node::new(1, "Shared").with(&["e-aaaa", "e-bbbb"]),
2712 Node::new(2, "Lonely"),
2713 ];
2714 let buckets = membership_index(&config, &issues);
2715 assert_eq!(
2716 buckets[&EpicId::new("e-aaaa").unwrap()],
2717 vec![IssueKey::new(1)]
2718 );
2719 assert_eq!(
2720 buckets[&EpicId::new("e-bbbb").unwrap()],
2721 vec![IssueKey::new(1)]
2722 );
2723 assert!(!buckets.values().flatten().any(|key| key.number() == 2));
2724 let config = registry(&[("e-cccc", "Empty")]);
2726 let buckets = membership_index(&config, &no_issues());
2727 assert_eq!(buckets.get(&EpicId::new("e-cccc").unwrap()), None);
2728 let index = EpicIndex::build(&config, &no_issues());
2729 assert_eq!(index.bucket(&EpicId::new("e-cccc").unwrap()), Some(&[][..]));
2730 assert!(index.bucket(&EpicId::new("e-zzzz").unwrap()).is_none());
2731 }
2732
2733 #[test]
2734 fn t007_a_missing_epic_reference_is_accounted_for_rather_than_dropped() {
2735 let config = registry(&[("e-aaaa", "Platform")]);
2736 let issues = vec![Node::new(4, "Dangling").with(&["e-gone", "e-aaaa"])];
2737 let index = EpicIndex::build(&config, &issues);
2738 assert_eq!(
2740 index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
2741 1
2742 );
2743 assert_eq!(index.missing().len(), 1);
2745 let missing = &index.missing()[0];
2746 assert_eq!(missing.target_kind, EpicReferenceKind::Epic);
2747 assert_eq!(missing.target, "e-gone");
2748 assert_eq!(missing.holder, ReferenceHolder::Issue(IssueKey::new(4)));
2749 assert_eq!(missing.holder.to_string(), "issue #4");
2750 assert!(missing.reason.contains("e-gone"));
2751 }
2752
2753 #[test]
2754 fn t007_no_member_list_is_stored_and_a_reference_change_leaves_the_record_alone() {
2755 let config = registry(&[("e-aaaa", "Platform")]);
2756 let issues = vec![
2757 Node::new(1, "Member").with(&["e-aaaa"]),
2758 Node::new(2, "Second"),
2759 ];
2760 let record_before = config.epics["e-aaaa"].clone();
2761 let change = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
2762 assert_eq!(ids(&change), vec!["e-aaaa"]);
2763 assert_eq!(config.epics["e-aaaa"], record_before);
2766 let serialized = toml::to_string(&config.epics["e-aaaa"]).unwrap();
2767 assert!(!serialized.contains("members"), "{serialized}");
2768 assert!(!serialized.contains("issues"), "{serialized}");
2769 }
2770
2771 #[test]
2772 fn t007_a_task_labelled_issue_is_a_member_like_any_other() {
2773 let task = Node::new(1, "A task").with(&["e-aaaa"]).labelled("task");
2775 let ordinary = Node::new(2, "An issue").with(&["e-aaaa"]);
2776 assert_eq!(task.labels, vec!["task".to_string()]);
2777
2778 let config = registry(&[("e-aaaa", "Platform")]);
2779 let issues = vec![task, ordinary];
2780 let index = EpicIndex::build(&config, &issues);
2781 let members = index.membership("e-aaaa").unwrap();
2782 assert_eq!(
2784 members
2785 .members
2786 .iter()
2787 .map(|m| m.issue.number())
2788 .collect::<Vec<_>>(),
2789 vec![1, 2]
2790 );
2791 assert_eq!(index.buckets().len(), 1);
2794 let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
2798 assert!(added.event.is_none(), "it already carries the reference");
2799 assert_eq!(ids(&added), vec!["e-aaaa"]);
2800 let changed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
2801 assert_eq!(ids(&changed), [] as [&str; 0]);
2802 assert_eq!(changed.event.unwrap().kind, EpicEventKind::EpicDereferenced);
2803 }
2804
2805 #[test]
2808 fn t008_a_three_deep_chain_reports_ancestors_children_and_depth() {
2809 let mut config = registry(&[("e-root", "Root")]);
2810 config
2811 .epics
2812 .insert("e-mid".to_string(), record("Mid", Some("e-root"), None));
2813 config
2814 .epics
2815 .insert("e-leaf".to_string(), record("Leaf", Some("e-mid"), None));
2816
2817 let index = EpicIndex::build(&config, &no_issues());
2818 let leaf = index.hierarchy("e-leaf").unwrap();
2819 assert_eq!(leaf.parent.unwrap().id.as_str(), "e-mid");
2820 assert_eq!(leaf.recorded_parent.as_deref(), Some("e-mid"));
2821 assert_eq!(leaf.depth, 2);
2822 assert_eq!(
2823 leaf.ancestors
2824 .iter()
2825 .map(|a| a.id.as_str())
2826 .collect::<Vec<_>>(),
2827 vec!["e-root", "e-mid"],
2828 "ancestors are root-first"
2829 );
2830 assert!(leaf.children.is_empty() && leaf.child_count == 0);
2831 assert!(leaf.unresolved.is_none());
2832
2833 let root = index.hierarchy("e-root").unwrap();
2834 assert!(root.parent.is_none() && root.ancestors.is_empty() && root.depth == 0);
2835 assert_eq!(
2836 root.children
2837 .iter()
2838 .map(|c| c.id.as_str())
2839 .collect::<Vec<_>>(),
2840 vec!["e-mid"]
2841 );
2842 assert_eq!(root.children[0].display, "Mid (e-mid)");
2843 }
2844
2845 #[test]
2846 fn t008_a_self_parent_is_rejected_before_any_write() {
2847 let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Other")]);
2848 let before = config.clone();
2849 let edit = EpicEdit {
2850 parent: Patch::Set("e-aaaa".to_string()),
2851 ..EpicEdit::default()
2852 };
2853 let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
2854 assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
2855 assert_eq!(err.kind().as_str(), "self_parent");
2856 assert_eq!(
2857 config, before,
2858 "a refusal leaves the registry byte-unchanged"
2859 );
2860 let by_name = EpicEdit {
2862 parent: Patch::Set("Platform".to_string()),
2863 ..EpicEdit::default()
2864 };
2865 assert_eq!(
2866 edit_epic(&mut config, "e-aaaa", &by_name)
2867 .unwrap_err()
2868 .kind(),
2869 EpicDiagnosticKind::SelfParent
2870 );
2871 assert_eq!(config, before);
2872 }
2873
2874 #[test]
2875 fn t008_a_cycle_closing_parent_is_rejected_with_the_path() {
2876 let mut config = registry(&[("e-aaaa", "A")]);
2877 config
2878 .epics
2879 .insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
2880 config
2881 .epics
2882 .insert("e-cccc".to_string(), record("C", Some("e-bbbb"), None));
2883 let before = config.epics.clone();
2884
2885 let edit = EpicEdit {
2887 parent: Patch::Set("e-cccc".to_string()),
2888 ..EpicEdit::default()
2889 };
2890 let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
2891 let EpicError::Cycle { path, .. } = &err else {
2892 panic!("expected a cycle refusal, got {err}");
2893 };
2894 assert_eq!(
2895 path.iter().map(EpicId::as_str).collect::<Vec<_>>(),
2896 vec!["e-aaaa", "e-cccc", "e-bbbb"]
2897 );
2898 assert!(err.message().contains("e-cccc"));
2899 assert_eq!(config.epics, before, "SC-005: nothing was written");
2900 assert!(config.epics["e-aaaa"].parent.is_none());
2901 }
2902
2903 #[test]
2904 fn t008_a_ten_thousand_node_chain_walks_without_overflow() {
2905 const N: usize = 10_000;
2908 let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
2909 registry.insert("e-00000".to_string(), record("Root", None, None));
2910 for n in 1..N {
2911 let parent = format!("e-{:05}", n - 1);
2912 registry.insert(format!("e-{n:05}"), record("Node", Some(&parent), None));
2913 }
2914
2915 let root = EpicId::new("e-00000").unwrap();
2917 let deepest = format!("e-{:05}", N - 1);
2918 let err = validate_epic_parent(®istry, &root, &deepest).unwrap_err();
2919 let EpicError::Cycle { path, .. } = &err else {
2920 panic!("expected a cycle refusal, got {err}");
2921 };
2922 assert_eq!(path.len(), N);
2923 assert_eq!(path[0].as_str(), "e-00000");
2924 assert_eq!(path[1].as_str(), deepest);
2925
2926 let fresh = EpicId::new("e-new").unwrap();
2928 assert_eq!(
2929 validate_epic_parent(®istry, &fresh, &deepest)
2930 .unwrap()
2931 .as_str(),
2932 deepest
2933 );
2934
2935 let config = Config {
2937 epics: registry,
2938 ..Config::default()
2939 };
2940 let index = EpicIndex::build(&config, &no_issues());
2941 assert_eq!(index.hierarchy(&deepest).unwrap().depth, N - 1);
2942 }
2943
2944 #[test]
2945 fn t008_a_parent_naming_a_deleted_epic_is_unresolved_not_dropped() {
2946 let mut config = registry(&[("e-aaaa", "Gone")]);
2947 config
2948 .epics
2949 .insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
2950 config.epics.shift_remove("e-aaaa");
2951
2952 let index = EpicIndex::build(&config, &no_issues());
2953 let hierarchy = index.hierarchy("e-bbbb").unwrap();
2954 assert!(hierarchy.parent.is_none(), "it does not resolve");
2955 assert_eq!(hierarchy.recorded_parent.as_deref(), Some("e-aaaa"));
2956 let unresolved = hierarchy.unresolved.unwrap();
2957 assert_eq!(unresolved.epic.as_str(), "e-bbbb");
2958 assert_eq!(unresolved.recorded_parent, "e-aaaa");
2959 assert!(unresolved.reason.contains("e-aaaa"));
2960 assert!(unresolved.reason.contains("e-bbbb"));
2961 assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
2963 let edit = EpicEdit {
2965 parent: Patch::Clear,
2966 ..EpicEdit::default()
2967 };
2968 let mut config = config;
2969 edit_epic(&mut config, "e-bbbb", &edit).unwrap();
2970 assert!(config.epics["e-bbbb"].parent.is_none());
2971 }
2972
2973 #[test]
2974 fn t008_entering_a_stored_cycle_is_not_closing_one() {
2975 let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
2978 registry.insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
2979 registry.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
2980 registry.insert("e-cccc".to_string(), record("C", Some("e-aaaa"), None));
2981 registry.insert("e-dddd".to_string(), record("D", None, None));
2982
2983 let c = EpicId::new("e-cccc").unwrap();
2986 assert_eq!(
2987 validate_epic_parent(®istry, &c, "e-aaaa")
2988 .unwrap()
2989 .as_str(),
2990 "e-aaaa"
2991 );
2992 let fresh = EpicId::new("e-new1").unwrap();
2993 assert_eq!(
2994 validate_epic_parent(®istry, &fresh, "e-bbbb")
2995 .unwrap()
2996 .as_str(),
2997 "e-bbbb"
2998 );
2999
3000 let defects = validate_epic_chains(®istry);
3003 assert_eq!(defects.len(), 1, "{defects:#?}");
3004 assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
3005 assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
3006
3007 let err = validate_epic_parent(®istry, &c, "e-cccc").unwrap_err();
3009 assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
3010 let closes = validate_epic_parent(®istry, &EpicId::new("e-dddd").unwrap(), "e-aaaa");
3011 assert_eq!(closes.unwrap().as_str(), "e-aaaa");
3012 }
3013
3014 #[test]
3015 fn t008_a_stored_self_parent_is_reported_once() {
3016 let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
3017 registry.insert("e-aaaa".to_string(), record("A", Some("e-aaaa"), None));
3018 registry.insert("e-bbbb".to_string(), record("B", None, None));
3019 let defects = validate_epic_chains(®istry);
3020 assert_eq!(defects.len(), 1, "one defect, not two: {defects:#?}");
3021 assert_eq!(defects[0].kind(), EpicDiagnosticKind::SelfParent);
3022 assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
3023 }
3024
3025 #[test]
3026 fn t006_the_reference_fields_are_writable_through_the_service() {
3027 let mut config = registry(&[("e-aaaa", "Platform")]);
3028 let created = create_epic(
3029 &mut config,
3030 &NewEpic {
3031 name: "Full".to_string(),
3032 projects: vec!["p-ok1".to_string()],
3033 milestone: Some("2026-Q4".to_string()),
3034 cycle: Some("October 2026".to_string()),
3035 ..NewEpic::default()
3036 },
3037 )
3038 .unwrap();
3039 assert_eq!(created.parent, None);
3040 assert_eq!(config.epics[created.id.as_str()].projects, vec!["p-ok1"]);
3041 assert_eq!(
3042 config.epics[created.id.as_str()].milestone.as_deref(),
3043 Some("2026-Q4")
3044 );
3045
3046 let edit = EpicEdit {
3049 projects: Patch::Set(vec!["p-gone".to_string(), "p-gone".to_string()]),
3050 milestone: Patch::Set("Nowhere".to_string()),
3051 cycle: Patch::Clear,
3052 ..EpicEdit::default()
3053 };
3054 let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
3055 assert_eq!(edited.projects, vec!["p-gone"]);
3056 assert_eq!(edited.milestone.as_deref(), Some("Nowhere"));
3057 assert_eq!(edited.cycle, None);
3058 let record = &config.epics["e-aaaa"];
3059 assert_eq!(
3060 record.projects,
3061 vec!["p-gone"],
3062 "a duplicate collapses to one"
3063 );
3064 assert_eq!(record.milestone.as_deref(), Some("Nowhere"));
3065 assert_eq!(record.cycle, None);
3066
3067 let found = resolve_references(epics(&config), &no_issues(), &ReferenceTargets::default());
3068 let kinds_of = |epic: &EpicId| -> Vec<EpicReferenceKind> {
3069 found
3070 .iter()
3071 .filter(|f| f.holder == ReferenceHolder::Epic(epic.clone()))
3072 .map(|f| f.target_kind)
3073 .collect()
3074 };
3075 assert_eq!(
3078 kinds_of(&EpicId::new("e-aaaa").unwrap()),
3079 vec![EpicReferenceKind::Project, EpicReferenceKind::Milestone]
3080 );
3081 assert_eq!(
3083 kinds_of(&created.id),
3084 vec![
3085 EpicReferenceKind::Project,
3086 EpicReferenceKind::Milestone,
3087 EpicReferenceKind::Cycle
3088 ]
3089 );
3090 }
3091
3092 #[test]
3093 fn t006_a_malformed_project_id_is_refused_before_any_write() {
3094 let mut config = registry(&[("e-aaaa", "Platform")]);
3095 let before = config.clone();
3096 let bad_edit = EpicEdit {
3097 projects: Patch::Set(vec!["Not A Project".to_string()]),
3098 ..EpicEdit::default()
3099 };
3100 let err = edit_epic(&mut config, "e-aaaa", &bad_edit).unwrap_err();
3101 assert_eq!(err.kind(), EpicDiagnosticKind::InvalidReference);
3102 assert_eq!(err.kind().as_str(), "invalid_reference");
3103 assert!(err.message().contains("Not A Project"));
3104 assert_eq!(config, before, "a refusal writes nothing");
3105
3106 let bad_create = create_epic(
3107 &mut config,
3108 &NewEpic {
3109 name: "Bad refs".to_string(),
3110 projects: vec![" p-ok1".to_string()],
3111 ..NewEpic::default()
3112 },
3113 );
3114 assert_eq!(
3115 bad_create.unwrap_err().kind(),
3116 EpicDiagnosticKind::InvalidReference
3117 );
3118 assert_eq!(config, before);
3119 }
3120
3121 #[test]
3122 fn t008_a_stored_cycle_is_reported_by_the_chain_validation() {
3123 let mut config = registry(&[("e-aaaa", "A")]);
3124 config
3125 .epics
3126 .insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
3127 config
3129 .epics
3130 .insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
3131 let defects = validate_epic_chains(epics(&config));
3132 assert_eq!(defects.len(), 1);
3133 assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
3134 let index = EpicIndex::build(&config, &no_issues());
3136 assert_eq!(index.hierarchy("e-aaaa").unwrap().depth, 1);
3137 }
3138
3139 #[test]
3142 fn t009_each_missing_reference_kind_reports_exactly_one_named_entry() {
3143 let mut config = registry(&[("e-aaaa", "Platform")]);
3144 config.epics.insert(
3145 "e-bbbb".to_string(),
3146 EpicConfiguration {
3147 name: "Broken".to_string(),
3148 parent: Some("e-aaaa".to_string()),
3149 projects: vec!["p-gone".to_string()],
3150 milestone: Some("2026-Q4".to_string()),
3151 cycle: Some("October 2026".to_string()),
3152 ..EpicConfiguration::default()
3153 },
3154 );
3155 let issues = vec![
3156 Node::new(1, "Dangling").with(&["e-missing"]),
3157 Node::new(2, "Malformed").with(&["Not An Id"]),
3158 ];
3159 let targets = targets(&[], &[], &[]);
3160 let found = resolve_references(epics(&config), &issues, &targets);
3161
3162 assert_eq!(found.len(), 5, "{found:#?}");
3163 for kind in [
3164 EpicReferenceKind::Epic,
3165 EpicReferenceKind::Project,
3166 EpicReferenceKind::Issue,
3167 EpicReferenceKind::Milestone,
3168 EpicReferenceKind::Cycle,
3169 ] {
3170 let matching: Vec<&EpicReferenceResolution> =
3171 found.iter().filter(|f| f.target_kind == kind).collect();
3172 assert_eq!(matching.len(), 1, "{kind} must be diagnosed once");
3173 let record = matching[0];
3174 assert!(!record.target.is_empty() || kind == EpicReferenceKind::Issue);
3175 assert_ne!(record.reason, "");
3176 assert!(record.reason.contains(&record.target) || record.target.is_empty());
3177 assert_eq!(
3178 kind.diagnostic_kind(),
3179 format!("missing_{}_reference", kind.as_str())
3180 );
3181 }
3182 let by_kind = |kind: EpicReferenceKind| {
3184 found
3185 .iter()
3186 .find(|f| f.target_kind == kind)
3187 .unwrap()
3188 .clone()
3189 };
3190 let epic_ref = by_kind(EpicReferenceKind::Epic);
3191 assert_eq!(epic_ref.target, "e-missing");
3192 assert_eq!(epic_ref.holder, ReferenceHolder::Issue(IssueKey::new(1)));
3193 assert_eq!(epic_ref.holder.to_string(), "issue #1");
3194 assert_eq!(by_kind(EpicReferenceKind::Project).target, "p-gone");
3195 assert_eq!(by_kind(EpicReferenceKind::Milestone).target, "2026-Q4");
3196 assert_eq!(by_kind(EpicReferenceKind::Cycle).target, "October 2026");
3197 assert_eq!(by_kind(EpicReferenceKind::Issue).target, "Not An Id");
3198 assert_eq!(
3199 by_kind(EpicReferenceKind::Issue).holder,
3200 ReferenceHolder::Issue(IssueKey::new(2))
3201 );
3202 }
3203
3204 #[test]
3205 fn t009_a_clean_snapshot_reports_nothing_and_a_resolvable_one_does_too() {
3206 let mut config = registry(&[("e-aaaa", "Platform")]);
3207 config.epics.insert(
3208 "e-bbbb".to_string(),
3209 EpicConfiguration {
3210 name: "Complete".to_string(),
3211 parent: Some("e-aaaa".to_string()),
3212 projects: vec!["p-aaaa".to_string()],
3213 milestone: Some("2026-Q4".to_string()),
3214 cycle: Some("October 2026".to_string()),
3215 ..EpicConfiguration::default()
3216 },
3217 );
3218 let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
3219 let targets = targets(&["p-aaaa"], &["2026-Q4"], &["October 2026"]);
3220 assert_eq!(
3221 resolve_references(epics(&config), &issues, &targets),
3222 [] as [crate::epics::EpicReferenceResolution; 0]
3223 );
3224 assert_eq!(
3226 resolve_references(
3227 epics(®istry(&[("e-aaaa", "Bare")])),
3228 &no_issues(),
3229 &targets
3230 ),
3231 [] as [crate::epics::EpicReferenceResolution; 0]
3232 );
3233 }
3234
3235 #[test]
3236 fn t009_two_runs_over_the_same_input_are_equal_and_ordered() {
3237 let mut config = registry(&[("e-bbbb", "B")]);
3238 config
3239 .epics
3240 .insert("e-aaaa".to_string(), record("A", Some("e-gone"), None));
3241 config
3242 .epics
3243 .insert("e-cccc".to_string(), record("C", Some("e-gone"), None));
3244 let issues = vec![
3245 Node::new(5, "Five").with(&["e-x1"]),
3246 Node::new(2, "Two").with(&["e-x2"]),
3247 ];
3248 let first = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
3249 let second = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
3250 assert_eq!(first, second);
3251 assert_eq!(
3253 first
3254 .iter()
3255 .map(|f| (f.holder.to_string(), f.target.clone()))
3256 .collect::<Vec<_>>(),
3257 vec![
3258 ("epic `e-aaaa`".to_string(), "e-gone".to_string()),
3259 ("epic `e-cccc`".to_string(), "e-gone".to_string()),
3260 ("issue #2".to_string(), "e-x2".to_string()),
3261 ("issue #5".to_string(), "e-x1".to_string()),
3262 ]
3263 );
3264 assert!(
3265 first
3266 .windows(2)
3267 .all(|w| reference_order(&w[0], &w[1]) != std::cmp::Ordering::Greater)
3268 );
3269 }
3270
3271 #[test]
3272 fn t009_targets_come_from_the_planner_lookups_not_a_second_store() {
3273 let mut config = Config::default();
3278 config.cycles.insert(
3279 "October 2026".to_string(),
3280 crate::config::PlanningCycle::default(),
3281 );
3282 let planner = Planner::from_config(config, PathBuf::from("."));
3283 let resolved = ReferenceTargets::from_planner(&planner).unwrap();
3284 assert!(resolved.cycle_known("October 2026"));
3285 assert!(resolved.cycle_known(DEFAULT_CYCLE_NAME));
3286 assert!(!resolved.cycle_known("Nowhere"));
3287 assert!(!resolved.milestone_known("2026-Q4"));
3288 }
3289
3290 #[test]
3293 fn t010_three_members_one_complete_report_done_one_of_three() {
3294 let config = registry(&[("e-aaaa", "Platform")]);
3295 let fixture = Blocking::new(
3296 vec![
3297 Node::new(1, "Complete").in_column("done").with(&["e-aaaa"]),
3298 Node::new(2, "Open").with(&["e-aaaa"]),
3299 Node::new(3, "Also open").with(&["e-aaaa"]),
3300 ],
3301 &[],
3302 );
3303 let views = fixture.views();
3304 let graph = RelationshipGraph::new(&views, "done");
3305 let figures = rollup(
3306 &config,
3307 &fixture.issues,
3308 "e-aaaa",
3309 &ReferenceTargets::default(),
3310 &graph,
3311 )
3312 .unwrap();
3313 assert_eq!(figures.progress.done, 1);
3314 assert_eq!(figures.progress.total, 3);
3315 assert_eq!(figures.progress.progress, Progress { done: 1, total: 3 });
3316 assert_eq!(figures.child_count, 0);
3317 assert_eq!(figures.blocked, 0);
3318 assert_eq!(figures.weighted_progress.total_weight, 0);
3319 assert_eq!(figures.weighted_progress.percent, None);
3320 }
3321
3322 #[test]
3323 fn t010_an_epic_with_no_members_reports_zeros() {
3324 let config = registry(&[("e-aaaa", "Platform")]);
3325 let fixture = Blocking::new(no_issues(), &[]);
3326 let views = fixture.views();
3327 let graph = RelationshipGraph::new(&views, "done");
3328 let figures = rollup(
3329 &config,
3330 &fixture.issues,
3331 "e-aaaa",
3332 &ReferenceTargets::default(),
3333 &graph,
3334 )
3335 .unwrap();
3336 assert_eq!(figures.progress.progress, Progress { done: 0, total: 0 });
3337 assert_eq!(figures.child_count, 0);
3338 assert_eq!(figures.blocked, 0);
3339 assert_eq!(
3340 figures.blocked_members,
3341 [] as [crate::epics::BlockedMember; 0]
3342 );
3343 assert_eq!(
3344 figures.missing_references,
3345 [] as [crate::epics::EpicReferenceResolution; 0]
3346 );
3347 }
3348
3349 #[test]
3350 fn t010_completion_resolves_from_the_configured_column_not_a_literal() {
3351 let mut config = custom_completion(
3353 &["triage", "wip", "shipped"],
3354 "shipped",
3355 &[
3356 ("triage", "unstarted"),
3357 ("wip", "active"),
3358 ("shipped", "completed"),
3359 ],
3360 );
3361 config.epics.insert(
3362 "e-aaaa".to_string(),
3363 EpicConfiguration {
3364 name: "Platform".to_string(),
3365 ..EpicConfiguration::default()
3366 },
3367 );
3368 let issues = vec![
3369 Node::new(1, "Shipped")
3370 .in_column("shipped")
3371 .with(&["e-aaaa"]),
3372 Node::new(2, "Literal done")
3373 .in_column("done")
3374 .with(&["e-aaaa"]),
3375 ];
3376 let fixture = Blocking::new(issues, &[]);
3377 let views = fixture.views();
3378 let graph = RelationshipGraph::new(&views, "done");
3379 let figures = rollup(
3380 &config,
3381 &fixture.issues,
3382 "e-aaaa",
3383 &ReferenceTargets::default(),
3384 &graph,
3385 )
3386 .unwrap();
3387 assert_eq!(
3388 figures.progress.done, 1,
3389 "only the configured column counts"
3390 );
3391 assert_eq!(figures.progress.total, 2);
3392 }
3393
3394 #[test]
3395 fn t011_the_known_fixture_counts_every_figure() {
3396 let mut config = registry(&[("e-parent", "Parent"), ("e-child", "Child")]);
3398 config.epics.insert(
3399 "e-parent".to_string(),
3400 EpicConfiguration {
3401 name: "Parent".to_string(),
3402 weight: Some(5),
3403 ..EpicConfiguration::default()
3404 },
3405 );
3406 config.epics.insert(
3407 "e-child".to_string(),
3408 EpicConfiguration {
3409 name: "Child".to_string(),
3410 parent: Some("e-parent".to_string()),
3411 weight: Some(3),
3412 projects: vec!["p-gone".to_string()],
3413 ..EpicConfiguration::default()
3414 },
3415 );
3416 let issues = vec![
3417 Node::new(1, "Complete")
3418 .in_column("done")
3419 .with(&["e-parent"]),
3420 Node::new(2, "Blocked").with(&["e-parent"]),
3421 Node::new(3, "Open").with(&["e-parent"]),
3422 Node::new(4, "Child member")
3424 .in_column("done")
3425 .with(&["e-child"]),
3426 ];
3427 let fixture = Blocking::new(issues, &[(3, 2)]);
3428 let views = fixture.views();
3429 let graph = RelationshipGraph::new(&views, "done");
3430 let figures = rollup(
3431 &config,
3432 &fixture.issues,
3433 "e-parent",
3434 &ReferenceTargets::default(),
3435 &graph,
3436 )
3437 .unwrap();
3438
3439 assert_eq!(figures.epic_id.as_str(), "e-parent");
3440 assert_eq!(figures.name, "Parent");
3441 assert_eq!(figures.progress.done, 1);
3442 assert_eq!(figures.progress.total, 3);
3443 assert_eq!(figures.child_count, 1);
3444 assert_eq!(figures.blocked, 1);
3445 assert_eq!(figures.blocked_members.len(), 1);
3446 assert_eq!(figures.blocked_members[0].issue.number(), 2);
3447 assert_ne!(figures.blocked_members[0].reason, "");
3448 let weighted = &figures.weighted_progress;
3449 assert_eq!(weighted.own_weight, Some(5));
3450 assert_eq!(weighted.weighted_children, 1);
3451 assert_eq!(weighted.unweighted_children, 0);
3452 assert_eq!(weighted.total_weight, 3);
3453 assert_eq!(weighted.done_weight, 3);
3454 assert_eq!(weighted.percent, Some(100));
3455 assert_eq!(
3457 figures.missing_references,
3458 [] as [crate::epics::EpicReferenceResolution; 0]
3459 );
3460 let child = rollup(
3463 &config,
3464 &fixture.issues,
3465 "e-child",
3466 &ReferenceTargets::default(),
3467 &graph,
3468 )
3469 .unwrap();
3470 assert_eq!(child.progress.progress, Progress { done: 1, total: 1 });
3471 assert_eq!(child.child_count, 0);
3472 assert_eq!(
3473 child
3474 .missing_references
3475 .iter()
3476 .map(|f| f.target_kind)
3477 .collect::<Vec<_>>(),
3478 vec![EpicReferenceKind::Project]
3479 );
3480 assert_eq!(child.weighted_progress.own_weight, Some(3));
3483 assert_eq!(
3485 figures,
3486 rollup(
3487 &config,
3488 &fixture.issues,
3489 "e-parent",
3490 &ReferenceTargets::default(),
3491 &graph
3492 )
3493 .unwrap()
3494 );
3495 }
3496
3497 #[test]
3498 fn t011_weighted_progress_excludes_an_unweighted_child() {
3499 let mut config = registry(&[("e-parent", "Parent")]);
3500 config.epics.insert(
3501 "e-light".to_string(),
3502 record("Light", Some("e-parent"), None),
3503 );
3504 config.epics.insert(
3505 "e-heavy".to_string(),
3506 record("Heavy", Some("e-parent"), Some(10)),
3507 );
3508 let fixture = Blocking::new(vec![], &[]);
3509 let views = fixture.views();
3510 let graph = RelationshipGraph::new(&views, "done");
3511 let figures = rollup(
3512 &config,
3513 &fixture.issues,
3514 "e-parent",
3515 &ReferenceTargets::default(),
3516 &graph,
3517 )
3518 .unwrap();
3519 let weighted = &figures.weighted_progress;
3520 assert_eq!(
3521 weighted.weighted_children, 1,
3522 "the unweighted child is excluded"
3523 );
3524 assert_eq!(weighted.unweighted_children, 1);
3525 assert_eq!(weighted.total_weight, 10, "not 10 + 0");
3526 assert_eq!(
3527 weighted.done_weight, 0,
3528 "a child with no members is not complete"
3529 );
3530 assert_eq!(weighted.percent, Some(0));
3531 }
3532
3533 #[test]
3534 fn t011_a_done_member_is_never_counted_as_blocked() {
3535 let config = registry(&[("e-aaaa", "Platform")]);
3536 let issues = vec![
3537 Node::new(1, "Done but claimed blocked")
3538 .in_column("done")
3539 .with(&["e-aaaa"]),
3540 Node::new(2, "Genuinely blocked").with(&["e-aaaa"]),
3541 Node::new(3, "An open blocker"),
3542 ];
3543 let fixture = Blocking::new(issues, &[(1, 1), (3, 1), (3, 2)]);
3546 let views = fixture.views();
3547 let graph = RelationshipGraph::new(&views, "done");
3548 let figures = rollup(
3549 &config,
3550 &fixture.issues,
3551 "e-aaaa",
3552 &ReferenceTargets::default(),
3553 &graph,
3554 )
3555 .unwrap();
3556 assert_eq!(figures.progress.done, 1);
3557 assert_eq!(figures.blocked, 1);
3558 assert_eq!(figures.blocked_members[0].issue.number(), 2);
3559 }
3560
3561 #[test]
3562 fn t011_the_blocked_read_is_consumed_from_the_relationship_graph() {
3563 let config = registry(&[("e-aaaa", "Platform")]);
3564 let fixture = Blocking::new(
3565 vec![
3566 Node::new(1, "Blocked").with(&["e-aaaa"]),
3567 Node::new(2, "Blocker"),
3568 ],
3569 &[(2, 1)],
3570 );
3571 let views = fixture.views();
3572 let graph = RelationshipGraph::new(&views, "done");
3573 let index = EpicIndex::build(&config, &fixture.issues);
3574 let blocked = index.blocked_members(&EpicId::new("e-aaaa").unwrap(), &graph);
3575 assert_eq!(blocked.len(), 1);
3576 let owner = graph.blocked_state(1);
3578 assert!(owner.is_blocked);
3579 assert_eq!(blocked[0].reason, owner.reason);
3580 }
3581
3582 #[test]
3583 fn t011_missing_references_are_visible_on_the_rollup() {
3584 let mut config = registry(&[("e-aaaa", "Platform")]);
3585 config.epics.insert(
3586 "e-aaaa".to_string(),
3587 EpicConfiguration {
3588 name: "Platform".to_string(),
3589 parent: Some("e-gone".to_string()),
3590 milestone: Some("Nowhere".to_string()),
3591 ..EpicConfiguration::default()
3592 },
3593 );
3594 let fixture = Blocking::new(vec![], &[]);
3595 let views = fixture.views();
3596 let graph = RelationshipGraph::new(&views, "done");
3597 let figures = rollup(
3598 &config,
3599 &fixture.issues,
3600 "e-aaaa",
3601 &ReferenceTargets::default(),
3602 &graph,
3603 )
3604 .unwrap();
3605 assert_eq!(
3606 figures
3607 .missing_references
3608 .iter()
3609 .map(|f| f.target_kind)
3610 .collect::<Vec<_>>(),
3611 vec![EpicReferenceKind::Epic, EpicReferenceKind::Milestone]
3612 );
3613 }
3614
3615 #[test]
3618 fn t012_reference_is_idempotent_and_each_accepted_change_has_one_event() {
3619 let config = registry(&[("e-aaaa", "Platform")]);
3620 let issues = vec![
3621 Node::new(1, "Member").with(&["e-aaaa"]),
3622 Node::new(2, "Second"),
3623 ];
3624
3625 let noop = reference_epic(&config, &issues, 1, "e-aaaa").unwrap();
3626 assert!(
3627 noop.event.is_none(),
3628 "an already-present reference changes nothing"
3629 );
3630 assert_eq!(ids(&noop), vec!["e-aaaa"]);
3631
3632 let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
3633 assert_eq!(ids(&added), vec!["e-aaaa"]);
3634 let event = added.event.unwrap();
3635 assert_eq!(event.kind, EpicEventKind::EpicReferenced);
3636 assert_eq!(event.kind.as_str(), "epic_referenced");
3637 assert_eq!(event.detail, "epic `e-aaaa` referenced");
3638
3639 let removed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
3640 assert_eq!(ids(&removed), [] as [&str; 0]);
3641 let event = removed.event.clone().unwrap();
3642 assert_eq!(event.kind.as_str(), "epic_dereferenced");
3643 assert_eq!(event.detail, "epic `e-aaaa` dereferenced");
3644
3645 let absent = dereference_epic(&issues, 2, "e-aaaa").unwrap();
3648 assert!(absent.event.is_none());
3649 assert_eq!(ids(&absent), [] as [&str; 0]);
3650 let stale = dereference_epic(&issues, 1, "e-gone").unwrap();
3651 assert!(stale.event.is_none());
3652 assert_eq!(ids(&stale), vec!["e-aaaa"]);
3653 assert!(
3655 dereference_epic(&issues, 1, "Platform")
3656 .unwrap()
3657 .event
3658 .is_none()
3659 );
3660 }
3661
3662 #[test]
3663 fn t012_bulk_reference_reports_each_selector_independently() {
3664 let config = registry(&[("e-aaaa", "Platform")]);
3665 let issues = vec![
3666 Node::new(1, "One").with(&["e-aaaa"]),
3667 Node::new(2, "Two"),
3668 Node::new(3, "Three"),
3669 ];
3670 let outcomes = bulk_reference(&config, &issues, "e-aaaa", &[1, 2, 99, 3]);
3671 assert_eq!(outcomes.len(), 4);
3672 assert_eq!(
3673 outcomes.iter().map(|o| o.selector).collect::<Vec<_>>(),
3674 vec![1, 2, 99, 3]
3675 );
3676 assert!(outcomes[0].outcome.as_ref().unwrap().event.is_none());
3677 assert!(outcomes[1].outcome.as_ref().unwrap().event.is_some());
3678 assert_eq!(
3680 outcomes[2].outcome.as_ref().unwrap_err().kind(),
3681 EpicDiagnosticKind::UnknownIssue
3682 );
3683 assert!(outcomes[3].outcome.as_ref().unwrap().event.is_some());
3684
3685 let all = bulk_reference(&config, &issues, "e-zzzz", &[1, 2]);
3687 assert_eq!(all.len(), 2);
3688 assert!(
3689 all.iter()
3690 .all(|o| o.outcome.as_ref().unwrap_err().kind() == EpicDiagnosticKind::UnknownEpic)
3691 );
3692 }
3693
3694 #[test]
3695 fn t012_reference_epic_refuses_an_unknown_issue() {
3696 let config = registry(&[("e-aaaa", "Platform")]);
3697 let err = reference_epic(&config, &no_issues(), 7, "e-aaaa").unwrap_err();
3698 assert_eq!(err.kind(), EpicDiagnosticKind::UnknownIssue);
3699 assert!(err.message().contains('7'));
3700 }
3701
3702 #[test]
3703 fn t012_every_operation_in_the_contract_is_reachable() {
3704 let mut config = registry(&[("e-aaaa", "Platform")]);
3707 config.epics.insert(
3708 "e-bbbb".to_string(),
3709 record("Child", Some("e-aaaa"), Some(2)),
3710 );
3711 let issues = vec![Node::new(1, "Member").in_column("done").with(&["e-aaaa"])];
3712 let fixture = Blocking::new(issues.clone(), &[]);
3713 let views = fixture.views();
3714 let graph = RelationshipGraph::new(&views, "done");
3715
3716 let _ = create_epic(
3717 &mut config,
3718 &NewEpic {
3719 name: "Created".to_string(),
3720 ..NewEpic::default()
3721 },
3722 )
3723 .unwrap();
3724 let _ = edit_epic(
3725 &mut config,
3726 "e-bbbb",
3727 &EpicEdit {
3728 status: Patch::Set("active".to_string()),
3729 projects: Patch::Set(vec!["p-ok1".to_string()]),
3730 milestone: Patch::Set("2026-Q4".to_string()),
3731 cycle: Patch::Set("October 2026".to_string()),
3732 ..EpicEdit::default()
3733 },
3734 )
3735 .unwrap();
3736 let _ = list_epics(&config, &issues);
3737 let _ = resolve(&config, &issues, "e-aaaa").unwrap();
3738 let _ = membership_index(&config, &issues);
3739 let _ = membership(&config, &issues, "e-aaaa").unwrap();
3740 let _ = hierarchy(&config, &issues, "e-aaaa").unwrap();
3741 let _ = reference_epic(&config, &issues, 1, "e-bbbb").unwrap();
3742 let _ = dereference_epic(&issues, 1, "e-aaaa").unwrap();
3743 let _ = bulk_reference(&config, &issues, "e-aaaa", &[1]);
3744 let _ = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
3745 let _ = rollup(
3746 &config,
3747 &fixture.issues,
3748 "e-aaaa",
3749 &ReferenceTargets::default(),
3750 &graph,
3751 )
3752 .unwrap();
3753 let deletion = delete_epic(&mut config, "e-bbbb", &issues).unwrap();
3754 assert_eq!(deletion.name, "Child");
3755
3756 assert_eq!(EpicDiagnosticKind::DuplicateName.as_str(), "duplicate_name");
3758 assert_eq!(EpicDiagnosticKind::UnknownEpic.as_str(), "unknown_epic");
3759 assert_eq!(EpicDiagnosticKind::UnknownIssue.as_str(), "unknown_issue");
3760 assert_eq!(EpicDiagnosticKind::InvalidDates.as_str(), "invalid_dates");
3761 assert_eq!(EpicDiagnosticKind::InvalidWeight.as_str(), "invalid_weight");
3762 assert_eq!(
3763 EpicDiagnosticKind::InvalidReference.as_str(),
3764 "invalid_reference"
3765 );
3766 assert_eq!(EpicDiagnosticKind::SelfParent.as_str(), "self_parent");
3767 assert_eq!(EpicDiagnosticKind::Cycle.as_str(), "cycle");
3768 assert_eq!(EPICS_KEY, "epics");
3769 assert_eq!(
3770 epic_display(&EpicId::new("e-aaaa").unwrap(), "Platform"),
3771 "Platform (e-aaaa)"
3772 );
3773 }
3774
3775 #[test]
3776 fn t012_the_module_is_filesystem_free() {
3777 let mut config = Config::default();
3782 let created = create_epic(
3783 &mut config,
3784 &NewEpic {
3785 name: "An epic".to_string(),
3786 weight: Some(1),
3787 ..NewEpic::default()
3788 },
3789 )
3790 .unwrap();
3791 let fixture = Blocking::new(
3792 vec![Node::new(1, "Member").with(&[created.id.as_str()])],
3793 &[],
3794 );
3795 let views = fixture.views();
3796 let graph = RelationshipGraph::new(&views, "done");
3797 let figures = rollup(
3798 &config,
3799 &fixture.issues,
3800 created.id.as_str(),
3801 &ReferenceTargets::default(),
3802 &graph,
3803 )
3804 .unwrap();
3805 assert_eq!(figures.progress.total, 1);
3806 assert_eq!(
3807 resolve_references(
3808 epics(&config),
3809 &fixture.issues,
3810 &ReferenceTargets::default()
3811 ),
3812 [] as [crate::epics::EpicReferenceResolution; 0]
3813 );
3814 }
3815}