1use std::collections::{HashMap, HashSet};
48use std::fmt;
49
50use indexmap::IndexMap;
51
52use crate::config::{Config, ProjectConfiguration, ProjectId};
53use crate::error::TixError;
54use crate::planning::Progress;
55
56pub const PROJECTS_KEY: &str = "projects";
61
62pub const MISSING_REFERENCE_KIND: &str = "missing_project_reference";
68
69pub trait ProjectNode {
75 fn number(&self) -> u32;
77 fn title(&self) -> &str;
79 fn status(&self) -> &str;
81 fn project_ids(&self) -> &[String];
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
94pub struct IssueKey(u32);
95
96impl IssueKey {
97 #[must_use]
99 pub fn new(number: u32) -> Self {
100 Self(number)
101 }
102
103 #[must_use]
105 pub fn number(self) -> u32 {
106 self.0
107 }
108}
109
110impl fmt::Display for IssueKey {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 write!(f, "#{}", self.0)
113 }
114}
115
116impl From<u32> for IssueKey {
117 fn from(number: u32) -> Self {
118 Self(number)
119 }
120}
121
122impl From<IssueKey> for u32 {
123 fn from(key: IssueKey) -> Self {
124 key.0
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct MemberRef {
134 pub issue: IssueKey,
136 pub display: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Project {
148 pub id: ProjectId,
150 pub name: String,
152 pub description: Option<String>,
154 pub status: Option<String>,
156 pub lead: Option<String>,
158 pub start_date: Option<String>,
160 pub due_date: Option<String>,
162 pub members: Vec<MemberRef>,
164 pub member_count: usize,
166}
167
168impl Project {
169 fn from_configuration(
171 id: ProjectId,
172 record: &ProjectConfiguration,
173 members: Vec<MemberRef>,
174 ) -> Self {
175 Self {
176 id,
177 name: record.name.clone(),
178 description: record.description.clone(),
179 status: record.status.clone(),
180 lead: record.lead.clone(),
181 start_date: record.start_date.clone(),
182 due_date: record.due_date.clone(),
183 member_count: members.len(),
184 members,
185 }
186 }
187}
188
189#[must_use]
195pub fn project_display(id: &ProjectId, name: &str) -> String {
196 format!("{name} ({id})")
197}
198
199pub const NO_PROJECT_BUCKET: &str = "(no project)";
201
202pub const MULTIPLE_PROJECTS_BUCKET: &str = "(multiple projects)";
210
211#[must_use]
224pub fn project_bucket_label(projects: &[String]) -> String {
225 match projects {
226 [] => NO_PROJECT_BUCKET.to_string(),
227 [only] => only.clone(),
228 _ => MULTIPLE_PROJECTS_BUCKET.to_string(),
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ProjectMembership {
235 pub project_id: ProjectId,
237 pub members: Vec<MemberRef>,
239 pub member_count: usize,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct MissingProjectReference {
250 pub issue: IssueKey,
252 pub issue_display: String,
254 pub project_id: String,
257 pub reason: String,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum ProjectDiagnosticKind {
264 UnknownProject,
266 UnknownIssue,
268 InvalidDates,
270 DuplicateName,
272}
273
274impl ProjectDiagnosticKind {
275 #[must_use]
277 pub fn as_str(self) -> &'static str {
278 match self {
279 ProjectDiagnosticKind::UnknownProject => "unknown_project",
280 ProjectDiagnosticKind::UnknownIssue => "unknown_issue",
281 ProjectDiagnosticKind::InvalidDates => "invalid_dates",
282 ProjectDiagnosticKind::DuplicateName => "duplicate_name",
283 }
284 }
285}
286
287impl fmt::Display for ProjectDiagnosticKind {
288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 f.write_str(self.as_str())
290 }
291}
292
293#[must_use]
295pub fn unknown_project_reason(reference: &str) -> String {
296 format!("no project matches `{reference}`; a project is selected by its stable id or by name")
297}
298
299pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
301 format!("issue `{selector}` is not in this store")
302}
303
304#[must_use]
309pub fn invalid_dates_reason(detail: &str) -> String {
310 format!("project dates are invalid: {detail}")
311}
312
313#[must_use]
319pub fn duplicate_name_reason(name: &str) -> String {
320 if name.trim().is_empty() {
321 "a project name must not be blank".to_string()
322 } else if name.trim() != name {
323 format!("project name `{name}` has leading or trailing whitespace")
324 } else {
325 format!("project name `{name}` is already in use; project names must be unique")
326 }
327}
328
329#[must_use]
331pub fn missing_reference_reason(issue: u32, project_id: &str) -> String {
332 format!(
333 "issue #{issue} references project `{project_id}`, which is not in the registry \
334 (the reference is kept so it can be repaired)"
335 )
336}
337
338#[must_use]
343pub fn association_detail(project_id: &str, associated: bool) -> String {
344 if associated {
345 format!("project `{project_id}` associated")
346 } else {
347 format!("project `{project_id}` dissociated")
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum ProjectError {
357 UnknownProject {
359 reference: String,
361 },
362 UnknownIssue {
364 selector: u32,
366 },
367 InvalidDates {
369 detail: String,
371 },
372 DuplicateName {
374 name: String,
376 },
377}
378
379impl ProjectError {
380 #[must_use]
382 pub fn kind(&self) -> ProjectDiagnosticKind {
383 match self {
384 ProjectError::UnknownProject { .. } => ProjectDiagnosticKind::UnknownProject,
385 ProjectError::UnknownIssue { .. } => ProjectDiagnosticKind::UnknownIssue,
386 ProjectError::InvalidDates { .. } => ProjectDiagnosticKind::InvalidDates,
387 ProjectError::DuplicateName { .. } => ProjectDiagnosticKind::DuplicateName,
388 }
389 }
390
391 #[must_use]
393 pub fn message(&self) -> String {
394 match self {
395 ProjectError::UnknownProject { reference } => unknown_project_reason(reference),
396 ProjectError::UnknownIssue { selector } => unknown_issue_reason(selector),
397 ProjectError::InvalidDates { detail } => invalid_dates_reason(detail),
398 ProjectError::DuplicateName { name } => duplicate_name_reason(name),
399 }
400 }
401}
402
403impl fmt::Display for ProjectError {
404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405 write!(f, "{}: {}", self.kind(), self.message())
406 }
407}
408
409impl std::error::Error for ProjectError {}
410
411impl From<ProjectError> for TixError {
417 fn from(err: ProjectError) -> TixError {
418 TixError::InvalidInput(err.to_string())
419 }
420}
421
422#[derive(Debug, Clone, Default, PartialEq, Eq)]
424pub struct NewProject {
425 pub name: String,
427 pub description: Option<String>,
429 pub status: Option<String>,
431 pub lead: Option<String>,
433 pub start_date: Option<String>,
435 pub due_date: Option<String>,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Default)]
445pub enum Patch<T> {
446 #[default]
448 Unchanged,
449 Clear,
451 Set(T),
453}
454
455impl<T> Patch<T> {
456 #[must_use]
458 pub fn is_unchanged(&self) -> bool {
459 matches!(self, Patch::Unchanged)
460 }
461}
462
463#[derive(Debug, Clone, Default, PartialEq, Eq)]
465pub struct ProjectEdit {
466 pub name: Patch<String>,
468 pub description: Patch<String>,
470 pub status: Patch<String>,
472 pub lead: Patch<String>,
474 pub start_date: Patch<String>,
476 pub due_date: Patch<String>,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct ProjectDeletion {
487 pub id: ProjectId,
489 pub name: String,
491 pub affected: Vec<MemberRef>,
493}
494
495fn normalize_optional(value: Option<String>) -> Option<String> {
500 value.filter(|v| !v.trim().is_empty())
501}
502
503fn validate_name(
510 config: &Config,
511 name: &str,
512 exclude: Option<&ProjectId>,
513) -> Result<(), ProjectError> {
514 if name.trim().is_empty() || name.trim() != name {
515 return Err(ProjectError::DuplicateName {
516 name: name.to_string(),
517 });
518 }
519 let normalized = name.to_lowercase();
520 let clash = config.projects.iter().any(|(raw, record)| {
521 let is_self = exclude.is_some_and(|id| raw.as_str() == id.as_str());
522 !is_self && record.name.to_lowercase() == normalized
523 });
524 if clash {
525 return Err(ProjectError::DuplicateName {
526 name: name.to_string(),
527 });
528 }
529 Ok(())
530}
531
532fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), ProjectError> {
534 crate::model::validate_issue_dates(start, due).map_err(|err| ProjectError::InvalidDates {
535 detail: err.to_string(),
536 })
537}
538
539pub fn resolve_project_id(config: &Config, reference: &str) -> Result<ProjectId, ProjectError> {
546 if let Ok(id) = ProjectId::new(reference) {
547 if config.projects.contains_key(id.as_str()) {
548 return Ok(id);
549 }
550 }
551 let normalized = reference.trim().to_lowercase();
552 if let Some((raw, _)) = config
553 .projects
554 .iter()
555 .find(|(_, record)| record.name.to_lowercase() == normalized)
556 {
557 return ProjectId::new(raw).map_err(|_| ProjectError::UnknownProject {
558 reference: reference.to_string(),
559 });
560 }
561 Err(ProjectError::UnknownProject {
562 reference: reference.to_string(),
563 })
564}
565
566pub fn create_project(config: &mut Config, new: &NewProject) -> Result<Project, ProjectError> {
572 validate_name(config, &new.name, None)?;
573 validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
574 let id = config.allocate_project_id();
575 let record = ProjectConfiguration {
576 name: new.name.clone(),
577 description: normalize_optional(new.description.clone()),
578 status: normalize_optional(new.status.clone()),
579 lead: normalize_optional(new.lead.clone()),
580 start_date: new.start_date.clone(),
581 due_date: new.due_date.clone(),
582 };
583 config.projects.insert(id.to_string(), record.clone());
584 Ok(Project::from_configuration(id, &record, Vec::new()))
585}
586
587pub fn edit_project(
593 config: &mut Config,
594 reference: &str,
595 edit: &ProjectEdit,
596) -> Result<Project, ProjectError> {
597 let id = resolve_project_id(config, reference)?;
598 let mut updated =
599 config
600 .projects
601 .get(id.as_str())
602 .cloned()
603 .ok_or_else(|| ProjectError::UnknownProject {
604 reference: reference.to_string(),
605 })?;
606
607 match &edit.name {
608 Patch::Unchanged => {}
609 Patch::Clear => {
610 return Err(ProjectError::DuplicateName {
611 name: String::new(),
612 });
613 }
614 Patch::Set(name) => {
615 validate_name(config, name, Some(&id))?;
616 updated.name.clone_from(name);
617 }
618 }
619 apply_optional(&edit.description, &mut updated.description);
620 apply_optional(&edit.status, &mut updated.status);
621 apply_optional(&edit.lead, &mut updated.lead);
622 apply_date(&edit.start_date, &mut updated.start_date);
623 apply_date(&edit.due_date, &mut updated.due_date);
624
625 validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
626
627 if let Some(record) = config.projects.get_mut(id.as_str()) {
628 *record = updated.clone();
629 }
630 Ok(Project::from_configuration(id, &updated, Vec::new()))
631}
632
633fn apply_optional(patch: &Patch<String>, slot: &mut Option<String>) {
635 match patch {
636 Patch::Unchanged => {}
637 Patch::Clear => *slot = None,
638 Patch::Set(value) => *slot = normalize_optional(Some(value.clone())),
639 }
640}
641
642fn apply_date(patch: &Patch<String>, slot: &mut Option<String>) {
644 match patch {
645 Patch::Unchanged => {}
646 Patch::Clear => *slot = None,
647 Patch::Set(value) => *slot = Some(value.clone()),
648 }
649}
650
651pub fn delete_project<N: ProjectNode>(
657 config: &mut Config,
658 reference: &str,
659 issues: &[N],
660) -> Result<ProjectDeletion, ProjectError> {
661 let id = resolve_project_id(config, reference)?;
662 let record =
663 config
664 .projects
665 .get(id.as_str())
666 .cloned()
667 .ok_or_else(|| ProjectError::UnknownProject {
668 reference: reference.to_string(),
669 })?;
670 let affected = ProjectIndex::build(config, issues)
671 .project(&id)
672 .map(|project| project.members.clone())
673 .unwrap_or_default();
674 config.projects.shift_remove(id.as_str());
675 Ok(ProjectDeletion {
676 id,
677 name: record.name,
678 affected,
679 })
680}
681
682#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct ProjectIndex {
693 projects: IndexMap<ProjectId, Project>,
695 buckets: HashMap<ProjectId, Vec<IssueKey>>,
697 missing: Vec<MissingProjectReference>,
699 labels: HashMap<IssueKey, String>,
701 statuses: HashMap<IssueKey, String>,
703}
704
705impl ProjectIndex {
706 pub fn build<N: ProjectNode>(config: &Config, issues: &[N]) -> Self {
712 let known: HashMap<String, ProjectId> = config
715 .projects
716 .keys()
717 .filter_map(|raw| ProjectId::new(raw).ok().map(|id| (raw.clone(), id)))
718 .collect();
719
720 let mut labels: HashMap<IssueKey, String> = HashMap::new();
721 let mut statuses: HashMap<IssueKey, String> = HashMap::new();
722 let mut buckets: HashMap<ProjectId, Vec<IssueKey>> = HashMap::new();
723 let mut missing: Vec<MissingProjectReference> = Vec::new();
724
725 for issue in issues {
726 let key = IssueKey::new(issue.number());
727 let display = crate::hierarchy::display_label(issue.number(), issue.title());
728 labels.insert(key, display.clone());
729 statuses.insert(key, issue.status().to_string());
730
731 let mut seen: HashSet<&str> = HashSet::new();
734 for raw in issue.project_ids() {
735 if !seen.insert(raw.as_str()) {
736 continue;
737 }
738 match known.get(raw.as_str()) {
739 Some(id) => buckets.entry(id.clone()).or_default().push(key),
740 None => missing.push(MissingProjectReference {
741 issue: key,
742 issue_display: display.clone(),
743 project_id: raw.clone(),
744 reason: missing_reference_reason(issue.number(), raw),
745 }),
746 }
747 }
748 }
749
750 for members in buckets.values_mut() {
751 members.sort_unstable();
752 }
753 missing.sort_by(|a, b| {
755 a.issue
756 .cmp(&b.issue)
757 .then_with(|| a.project_id.cmp(&b.project_id))
758 });
759
760 let mut projects: IndexMap<ProjectId, Project> = IndexMap::new();
761 for (raw, record) in &config.projects {
762 let Ok(id) = ProjectId::new(raw) else {
763 continue;
764 };
765 let members: Vec<MemberRef> = buckets
766 .get(&id)
767 .map(|keys| {
768 keys.iter()
769 .map(|key| MemberRef {
770 issue: *key,
771 display: labels.get(key).cloned().unwrap_or_else(|| key.to_string()),
772 })
773 .collect()
774 })
775 .unwrap_or_default();
776 projects.insert(id.clone(), Project::from_configuration(id, record, members));
777 }
778
779 Self {
780 projects,
781 buckets,
782 missing,
783 labels,
784 statuses,
785 }
786 }
787
788 #[must_use]
790 pub fn buckets(&self) -> &HashMap<ProjectId, Vec<IssueKey>> {
791 &self.buckets
792 }
793
794 #[must_use]
800 pub fn bucket(&self, id: &ProjectId) -> Option<&[IssueKey]> {
801 if !self.projects.contains_key(id) {
802 return None;
803 }
804 Some(self.buckets.get(id).map_or(&[][..], Vec::as_slice))
805 }
806
807 #[must_use]
809 pub fn projects(&self) -> Vec<&Project> {
810 self.projects.values().collect()
811 }
812
813 #[must_use]
815 pub fn project(&self, id: &ProjectId) -> Option<&Project> {
816 self.projects.get(id)
817 }
818
819 #[must_use]
821 pub fn label(&self, key: IssueKey) -> Option<&str> {
822 self.labels.get(&key).map(String::as_str)
823 }
824
825 #[must_use]
827 pub fn status_of(&self, key: IssueKey) -> Option<&str> {
828 self.statuses.get(&key).map(String::as_str)
829 }
830
831 #[must_use]
833 pub fn missing(&self) -> &[MissingProjectReference] {
834 &self.missing
835 }
836
837 pub fn resolve(&self, reference: &str) -> Result<&Project, ProjectError> {
839 if let Ok(id) = ProjectId::new(reference) {
840 if let Some(project) = self.projects.get(&id) {
841 return Ok(project);
842 }
843 }
844 let normalized = reference.trim().to_lowercase();
845 self.projects
846 .values()
847 .find(|project| project.name.to_lowercase() == normalized)
848 .ok_or_else(|| ProjectError::UnknownProject {
849 reference: reference.to_string(),
850 })
851 }
852
853 pub fn membership(&self, reference: &str) -> Result<ProjectMembership, ProjectError> {
855 let project = self.resolve(reference)?;
856 Ok(ProjectMembership {
857 project_id: project.id.clone(),
858 members: project.members.clone(),
859 member_count: project.member_count,
860 })
861 }
862
863 #[must_use]
875 pub fn resolved_memberships(&self) -> HashMap<u32, Vec<String>> {
876 let mut out: HashMap<u32, Vec<String>> = HashMap::new();
877 for project in self.projects.values() {
878 if let Some(members) = self.buckets.get(&project.id) {
879 for member in members {
880 out.entry(member.number())
881 .or_default()
882 .push(project.id.to_string());
883 }
884 }
885 }
886 out
887 }
888
889 #[must_use]
891 pub fn into_buckets(self) -> HashMap<ProjectId, Vec<IssueKey>> {
892 self.buckets
893 }
894
895 #[must_use]
897 pub fn into_missing(self) -> Vec<MissingProjectReference> {
898 self.missing
899 }
900
901 #[must_use]
903 pub fn into_projects(self) -> Vec<Project> {
904 self.projects.into_values().collect()
905 }
906}
907
908#[must_use]
912pub fn membership_index<N: ProjectNode>(
913 config: &Config,
914 issues: &[N],
915) -> HashMap<ProjectId, Vec<IssueKey>> {
916 ProjectIndex::build(config, issues).into_buckets()
917}
918
919pub fn membership<N: ProjectNode>(
921 config: &Config,
922 issues: &[N],
923 reference: &str,
924) -> Result<ProjectMembership, ProjectError> {
925 ProjectIndex::build(config, issues).membership(reference)
926}
927
928#[must_use]
933pub fn list_projects<N: ProjectNode>(config: &Config, issues: &[N]) -> Vec<Project> {
934 ProjectIndex::build(config, issues).into_projects()
935}
936
937pub fn resolve<N: ProjectNode>(
939 config: &Config,
940 issues: &[N],
941 reference: &str,
942) -> Result<Project, ProjectError> {
943 ProjectIndex::build(config, issues)
944 .resolve(reference)
945 .cloned()
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
958pub enum ProjectEventKind {
959 ProjectAssociated,
961 ProjectDissociated,
963}
964
965impl ProjectEventKind {
966 #[must_use]
968 pub fn as_str(self) -> &'static str {
969 match self {
970 ProjectEventKind::ProjectAssociated => "project_associated",
971 ProjectEventKind::ProjectDissociated => "project_dissociated",
972 }
973 }
974}
975
976#[derive(Debug, Clone, PartialEq, Eq)]
978pub struct ProjectEvent {
979 pub kind: ProjectEventKind,
981 pub project_id: String,
984 pub detail: String,
986}
987
988#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct ProjectChange {
995 pub issue: IssueKey,
997 pub project_ids: Vec<String>,
999 pub event: Option<ProjectEvent>,
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Eq)]
1012pub struct BulkProjectOutcome {
1013 pub selector: u32,
1015 pub outcome: Result<ProjectChange, ProjectError>,
1017}
1018
1019fn find_issue<N: ProjectNode>(issues: &[N], selector: u32) -> Result<&N, ProjectError> {
1021 issues
1022 .iter()
1023 .find(|issue| issue.number() == selector)
1024 .ok_or(ProjectError::UnknownIssue { selector })
1025}
1026
1027fn associate_resolved<N: ProjectNode>(
1029 issues: &[N],
1030 selector: u32,
1031 id: &ProjectId,
1032) -> Result<ProjectChange, ProjectError> {
1033 let issue = find_issue(issues, selector)?;
1034 let current = issue.project_ids();
1035 let key = IssueKey::new(selector);
1036 if current.iter().any(|stored| stored == id.as_str()) {
1037 return Ok(ProjectChange {
1038 issue: key,
1039 project_ids: current.to_vec(),
1040 event: None,
1041 });
1042 }
1043 let mut next = current.to_vec();
1044 next.push(id.to_string());
1045 Ok(ProjectChange {
1046 issue: key,
1047 project_ids: next,
1048 event: Some(ProjectEvent {
1049 kind: ProjectEventKind::ProjectAssociated,
1050 project_id: id.to_string(),
1051 detail: association_detail(id.as_str(), true),
1052 }),
1053 })
1054}
1055
1056pub fn associate<N: ProjectNode>(
1062 config: &Config,
1063 issues: &[N],
1064 issue_selector: u32,
1065 project_reference: &str,
1066) -> Result<ProjectChange, ProjectError> {
1067 let id = resolve_project_id(config, project_reference)?;
1068 associate_resolved(issues, issue_selector, &id)
1069}
1070
1071pub fn dissociate<N: ProjectNode>(
1085 issues: &[N],
1086 issue_selector: u32,
1087 project_id: &str,
1088) -> Result<ProjectChange, ProjectError> {
1089 let issue = find_issue(issues, issue_selector)?;
1090 let current = issue.project_ids();
1091 let key = IssueKey::new(issue_selector);
1092 if !current.iter().any(|stored| stored == project_id) {
1093 return Ok(ProjectChange {
1094 issue: key,
1095 project_ids: current.to_vec(),
1096 event: None,
1097 });
1098 }
1099 let next: Vec<String> = current
1100 .iter()
1101 .filter(|stored| stored.as_str() != project_id)
1102 .cloned()
1103 .collect();
1104 Ok(ProjectChange {
1105 issue: key,
1106 project_ids: next,
1107 event: Some(ProjectEvent {
1108 kind: ProjectEventKind::ProjectDissociated,
1109 project_id: project_id.to_string(),
1110 detail: association_detail(project_id, false),
1111 }),
1112 })
1113}
1114
1115#[must_use]
1121pub fn bulk_associate<N: ProjectNode>(
1122 config: &Config,
1123 issues: &[N],
1124 project_reference: &str,
1125 issue_selectors: &[u32],
1126) -> Vec<BulkProjectOutcome> {
1127 let resolved = resolve_project_id(config, project_reference);
1128 issue_selectors
1129 .iter()
1130 .map(|&selector| {
1131 let outcome = match &resolved {
1132 Ok(id) => associate_resolved(issues, selector, id),
1133 Err(err) => Err(err.clone()),
1134 };
1135 BulkProjectOutcome { selector, outcome }
1136 })
1137 .collect()
1138}
1139
1140#[must_use]
1151pub fn validate_references<N: ProjectNode>(
1152 config: &Config,
1153 issues: &[N],
1154) -> Vec<MissingProjectReference> {
1155 ProjectIndex::build(config, issues).into_missing()
1156}
1157
1158pub trait HealthNode: ProjectNode {
1179 fn assignee(&self) -> Option<&str>;
1182}
1183
1184#[must_use]
1192pub fn assigned_keys<N: HealthNode>(issues: &[N]) -> HashSet<u32> {
1193 issues
1194 .iter()
1195 .filter(|issue| {
1196 issue
1197 .assignee()
1198 .is_some_and(|assignee| !assignee.trim().is_empty())
1199 })
1200 .map(ProjectNode::number)
1201 .collect()
1202}
1203
1204#[derive(Debug, Clone, PartialEq, Eq)]
1211pub struct ProjectProgress {
1212 pub done: usize,
1214 pub total: usize,
1216 pub progress: Progress,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq)]
1226pub struct ProjectHealth {
1227 pub overdue: usize,
1229 pub blocked: usize,
1231 pub unassigned: usize,
1233 pub missing_references: Vec<MissingProjectReference>,
1235}
1236
1237#[derive(Debug, Clone, PartialEq, Eq)]
1242pub struct ProjectHealthReport {
1243 pub project_id: ProjectId,
1245 pub name: String,
1247 pub progress: ProjectProgress,
1249 pub health: ProjectHealth,
1251}
1252
1253impl ProjectIndex {
1254 pub fn health<S: std::hash::BuildHasher>(
1270 &self,
1271 config: &Config,
1272 reference: &str,
1273 assigned: &HashSet<u32, S>,
1274 overdue: &HashSet<u32, S>,
1275 blocked: &HashSet<u32, S>,
1276 ) -> Result<ProjectHealthReport, ProjectError> {
1277 let project = self.resolve(reference)?;
1278 let (_, resolution) = crate::workflow::active_resolution(config);
1279 let members = self.bucket(&project.id).unwrap_or(&[]);
1280 let mut done = 0usize;
1281 let mut overdue_count = 0usize;
1282 let mut blocked_count = 0usize;
1283 let mut unassigned = 0usize;
1284 for key in members {
1285 let number = key.number();
1286 let complete = self
1287 .status_of(*key)
1288 .and_then(|status| resolution.resolve(status))
1289 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1290 if complete {
1291 done += 1;
1292 }
1293 if !complete && overdue.contains(&number) {
1294 overdue_count += 1;
1295 }
1296 if blocked.contains(&number) {
1297 blocked_count += 1;
1298 }
1299 if !assigned.contains(&number) {
1300 unassigned += 1;
1301 }
1302 }
1303 let total = members.len();
1304 let member_keys: HashSet<IssueKey> = members.iter().copied().collect();
1310 let missing_references: Vec<MissingProjectReference> = self
1311 .missing
1312 .iter()
1313 .filter(|missing| member_keys.contains(&missing.issue))
1314 .cloned()
1315 .collect();
1316 Ok(ProjectHealthReport {
1317 project_id: project.id.clone(),
1318 name: project.name.clone(),
1319 progress: ProjectProgress {
1320 done,
1321 total,
1322 progress: Progress { done, total },
1323 },
1324 health: ProjectHealth {
1325 overdue: overdue_count,
1326 blocked: blocked_count,
1327 unassigned,
1328 missing_references,
1329 },
1330 })
1331 }
1332}
1333
1334pub fn health<N: HealthNode, S: std::hash::BuildHasher>(
1340 config: &Config,
1341 issues: &[N],
1342 reference: &str,
1343 assigned: &HashSet<u32, S>,
1344 overdue: &HashSet<u32, S>,
1345 blocked: &HashSet<u32, S>,
1346) -> Result<ProjectHealthReport, ProjectError> {
1347 ProjectIndex::build(config, issues).health(config, reference, assigned, overdue, blocked)
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 #[allow(clippy::wildcard_imports)]
1353 use super::*;
1354
1355 #[derive(Debug, Clone, PartialEq, Eq)]
1361 struct Node {
1362 number: u32,
1363 title: String,
1364 status: String,
1365 projects: Vec<String>,
1366 assignee: Option<String>,
1367 }
1368
1369 impl Node {
1370 fn new(number: u32, title: &str) -> Self {
1371 Self {
1372 number,
1373 title: title.to_string(),
1374 status: "todo".to_string(),
1375 projects: Vec::new(),
1376 assignee: None,
1377 }
1378 }
1379
1380 fn with(mut self, ids: &[&str]) -> Self {
1381 self.projects = ids.iter().map(|id| (*id).to_string()).collect();
1382 self
1383 }
1384
1385 fn in_column(mut self, status: &str) -> Self {
1386 self.status = status.to_string();
1387 self
1388 }
1389
1390 fn assigned_to(mut self, assignee: &str) -> Self {
1391 self.assignee = Some(assignee.to_string());
1392 self
1393 }
1394 }
1395
1396 impl ProjectNode for Node {
1397 fn number(&self) -> u32 {
1398 self.number
1399 }
1400 fn title(&self) -> &str {
1401 &self.title
1402 }
1403 fn status(&self) -> &str {
1404 &self.status
1405 }
1406 fn project_ids(&self) -> &[String] {
1407 &self.projects
1408 }
1409 }
1410
1411 impl HealthNode for Node {
1412 fn assignee(&self) -> Option<&str> {
1413 self.assignee.as_deref()
1414 }
1415 }
1416
1417 fn registry(entries: &[(&str, &str)]) -> Config {
1419 let mut config = Config::default();
1420 for (id, name) in entries {
1421 config.projects.insert(
1422 (*id).to_string(),
1423 ProjectConfiguration {
1424 name: (*name).to_string(),
1425 ..ProjectConfiguration::default()
1426 },
1427 );
1428 }
1429 config
1430 }
1431
1432 fn ids(change: &ProjectChange) -> Vec<&str> {
1433 change.project_ids.iter().map(String::as_str).collect()
1434 }
1435
1436 fn set(numbers: &[u32]) -> HashSet<u32> {
1438 numbers.iter().copied().collect()
1439 }
1440
1441 fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
1444 let mut config = Config::default();
1445 config.kanban.configurations.insert(
1446 "default".to_string(),
1447 crate::config::BoardConfiguration {
1448 columns: columns.iter().map(|c| (*c).to_string()).collect(),
1449 done_column: Some(done_column.to_string()),
1450 category_map: map
1451 .iter()
1452 .map(|(column, key)| ((*column).to_string(), (*key).to_string()))
1453 .collect(),
1454 ..Default::default()
1455 },
1456 );
1457 config
1458 }
1459
1460 #[test]
1463 fn create_project_allocates_a_stable_id() {
1464 let mut config = Config::default();
1465 let project = create_project(
1466 &mut config,
1467 &NewProject {
1468 name: "Platform hardening".into(),
1469 description: Some("reduce risk".into()),
1470 ..NewProject::default()
1471 },
1472 )
1473 .unwrap();
1474
1475 assert!(project.id.as_str().starts_with("p-"));
1476 assert_eq!(project.name, "Platform hardening");
1477 assert_eq!(project.member_count, 0);
1478 assert!(config.projects.contains_key(project.id.as_str()));
1479 assert_eq!(
1481 config.projects[project.id.as_str()].name,
1482 "Platform hardening"
1483 );
1484 }
1485
1486 #[test]
1487 fn create_project_refuses_a_duplicate_name_case_folded() {
1488 let mut config = registry(&[("p-aaaa", "Platform")]);
1489 let err = create_project(
1490 &mut config,
1491 &NewProject {
1492 name: "platform".into(),
1493 ..NewProject::default()
1494 },
1495 )
1496 .unwrap_err();
1497 assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
1498 assert_eq!(config.projects.len(), 1, "a refusal writes nothing");
1499 }
1500
1501 #[test]
1502 fn create_project_refuses_inverted_dates() {
1503 let mut config = Config::default();
1504 let err = create_project(
1505 &mut config,
1506 &NewProject {
1507 name: "Bad dates".into(),
1508 start_date: Some("2026-12-01".into()),
1509 due_date: Some("2026-01-01".into()),
1510 ..NewProject::default()
1511 },
1512 )
1513 .unwrap_err();
1514 assert_eq!(err.kind(), ProjectDiagnosticKind::InvalidDates);
1515 assert!(config.projects.is_empty());
1516 }
1517
1518 #[test]
1519 fn rename_changes_the_label_only() {
1520 let mut config = registry(&[("p-3f9a2c", "Platform hardening")]);
1521 let issues = vec![Node::new(1, "One").with(&["p-3f9a2c"])];
1522
1523 let edited = edit_project(
1524 &mut config,
1525 "p-3f9a2c",
1526 &ProjectEdit {
1527 name: Patch::Set("Platform".into()),
1528 ..ProjectEdit::default()
1529 },
1530 )
1531 .unwrap();
1532
1533 assert!(config.projects.contains_key("p-3f9a2c"));
1535 assert_eq!(edited.id.as_str(), "p-3f9a2c");
1536 assert_eq!(edited.name, "Platform");
1537 let membership = membership(&config, &issues, "p-3f9a2c").unwrap();
1538 assert_eq!(membership.member_count, 1);
1539 }
1540
1541 #[test]
1542 fn edit_project_clears_an_optional_field() {
1543 let mut config = registry(&[("p-aaaa", "One")]);
1544 config.projects["p-aaaa"].lead = Some("Alice".into());
1545 let edited = edit_project(
1546 &mut config,
1547 "p-aaaa",
1548 &ProjectEdit {
1549 lead: Patch::Clear,
1550 ..ProjectEdit::default()
1551 },
1552 )
1553 .unwrap();
1554 assert_eq!(edited.lead, None);
1555 assert_eq!(config.projects["p-aaaa"].lead, None);
1556 }
1557
1558 #[test]
1559 fn edit_project_refuses_an_unknown_reference() {
1560 let mut config = registry(&[("p-aaaa", "One")]);
1561 let err = edit_project(
1562 &mut config,
1563 "does-not-exist",
1564 &ProjectEdit {
1565 name: Patch::Set("X".into()),
1566 ..ProjectEdit::default()
1567 },
1568 )
1569 .unwrap_err();
1570 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1571 }
1572
1573 #[test]
1574 fn delete_reports_affected_issues_and_keeps_them() {
1575 let mut config = registry(&[("p-aaaa", "One")]);
1576 let issues = vec![
1577 Node::new(2, "Two").with(&["p-aaaa"]),
1578 Node::new(7, "Seven").with(&["p-aaaa"]),
1579 ];
1580
1581 let impact = delete_project(&mut config, "One", &issues).unwrap();
1582
1583 assert_eq!(impact.id.as_str(), "p-aaaa");
1584 assert_eq!(impact.name, "One");
1585 assert_eq!(
1586 impact
1587 .affected
1588 .iter()
1589 .map(|m| m.issue.number())
1590 .collect::<Vec<_>>(),
1591 vec![2, 7],
1592 "affected issues are ordered by issue number"
1593 );
1594 assert!(config.projects.is_empty());
1596 assert_eq!(issues[0].projects, vec!["p-aaaa".to_string()]);
1597 assert_eq!(validate_references(&config, &issues).len(), 2);
1599 }
1600
1601 #[test]
1602 fn successive_create_draws_after_a_delete_are_distinct() {
1603 let mut config = Config::default();
1610 let first = create_project(
1611 &mut config,
1612 &NewProject {
1613 name: "One".into(),
1614 ..NewProject::default()
1615 },
1616 )
1617 .unwrap();
1618 delete_project(&mut config, first.id.as_str(), &[] as &[Node]).unwrap();
1619 let second = create_project(
1620 &mut config,
1621 &NewProject {
1622 name: "One".into(),
1623 ..NewProject::default()
1624 },
1625 )
1626 .unwrap();
1627 assert_ne!(first.id, second.id, "two draws must be distinct");
1628 }
1629
1630 #[test]
1631 fn resolve_by_id_and_by_name_agree() {
1632 let config = registry(&[("p-aaaa", "Platform"), ("p-bbbb", "Docs")]);
1633 let issues = vec![Node::new(1, "One").with(&["p-bbbb"])];
1634 let by_id = resolve(&config, &issues, "p-bbbb").unwrap();
1635 let by_name = resolve(&config, &issues, "Docs").unwrap();
1636 assert_eq!(by_id, by_name);
1637 assert_eq!(by_id.member_count, 1);
1638 }
1639
1640 #[test]
1641 fn id_resolution_takes_precedence_over_a_name_lookalike() {
1642 let mut config = registry(&[("p-aaaa", "Platform"), ("Docs", "Docs refresh")]);
1643 config.projects["Docs"].name = "Docs refresh".into();
1645 let resolved = resolve_project_id(&config, "Docs").unwrap();
1646 assert_eq!(resolved.as_str(), "Docs", "the id is matched first");
1647 }
1648
1649 #[test]
1650 fn unknown_id_and_unknown_name_are_refused() {
1651 let config = registry(&[("p-aaaa", "Platform")]);
1652 for reference in ["p-missing", "No such name"] {
1653 let err = resolve_project_id(&config, reference).unwrap_err();
1654 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1655 assert!(err.message().contains(reference));
1656 }
1657 }
1658
1659 #[test]
1660 fn list_projects_is_declaration_ordered_with_member_counts() {
1661 let config = registry(&[("p-zeta", "Zeta"), ("p-alpha", "Alpha")]);
1662 let issues = vec![
1663 Node::new(3, "Three").with(&["p-alpha"]),
1664 Node::new(1, "One").with(&["p-zeta", "p-alpha"]),
1665 ];
1666 let projects = list_projects(&config, &issues);
1667 assert_eq!(
1668 projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
1669 ["Zeta", "Alpha"],
1670 "declaration order is the display order"
1671 );
1672 assert_eq!(projects[0].member_count, 1);
1673 assert_eq!(projects[1].member_count, 2);
1674 }
1675
1676 #[test]
1677 fn list_projects_never_errors_on_an_empty_registry() {
1678 let config = Config::default();
1679 assert_eq!(
1680 list_projects(&config, &[] as &[Node]),
1681 [] as [crate::projects::Project; 0]
1682 );
1683 }
1684
1685 #[test]
1686 fn project_display_shows_the_id_beside_the_name() {
1687 let id = ProjectId::new("p-3f9a2c").unwrap();
1688 assert_eq!(project_display(&id, "Platform"), "Platform (p-3f9a2c)");
1689 }
1690
1691 #[test]
1694 fn an_issue_may_belong_to_several_projects() {
1695 let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1696 let issues = vec![Node::new(1, "Shared").with(&["p-aaaa", "p-bbbb"])];
1697 assert_eq!(
1698 membership(&config, &issues, "p-aaaa").unwrap().member_count,
1699 1
1700 );
1701 assert_eq!(
1702 membership(&config, &issues, "p-bbbb").unwrap().member_count,
1703 1
1704 );
1705 }
1706
1707 #[test]
1708 fn an_unassociated_issue_is_in_no_bucket() {
1709 let config = registry(&[("p-aaaa", "One")]);
1710 let issues = vec![Node::new(1, "None"), Node::new(2, "One").with(&["p-aaaa"])];
1711 let index = ProjectIndex::build(&config, &issues);
1712 let id = ProjectId::new("p-aaaa").unwrap();
1713 assert_eq!(index.bucket(&id).unwrap(), [IssueKey::new(2)]);
1714 }
1715
1716 #[test]
1717 fn bucket_is_none_only_for_an_unknown_project() {
1718 let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1721 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1722 let index = ProjectIndex::build(&config, &issues);
1723
1724 let known_empty = ProjectId::new("p-bbbb").unwrap();
1725 assert_eq!(index.bucket(&known_empty), Some(&[][..]));
1726
1727 let unknown = ProjectId::new("p-missing").unwrap();
1728 assert_eq!(index.bucket(&unknown), None);
1729 }
1730
1731 #[test]
1732 fn membership_order_is_stable_across_repeated_builds() {
1733 let config = registry(&[("p-aaaa", "One")]);
1734 let issues = vec![
1736 Node::new(9, "Nine").with(&["p-aaaa"]),
1737 Node::new(2, "Two").with(&["p-aaaa"]),
1738 Node::new(5, "Five").with(&["p-aaaa"]),
1739 ];
1740 let first = membership(&config, &issues, "p-aaaa").unwrap();
1741 let second = membership(&config, &issues, "p-aaaa").unwrap();
1742 assert_eq!(first, second);
1743 assert_eq!(
1744 first
1745 .members
1746 .iter()
1747 .map(|m| m.issue.number())
1748 .collect::<Vec<_>>(),
1749 vec![2, 5, 9]
1750 );
1751 assert_eq!(first.members[0].display, "#2 Two");
1752 }
1753
1754 #[test]
1755 fn the_free_operations_agree_with_one_built_index() {
1756 let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1761 let issues = vec![
1762 Node::new(1, "One").with(&["p-aaaa", "p-bbbb"]),
1763 Node::new(2, "Two").with(&["p-aaaa"]),
1764 ];
1765 let index = ProjectIndex::build(&config, &issues);
1766
1767 assert_eq!(membership_index(&config, &issues), *index.buckets());
1768 assert_eq!(
1769 membership(&config, &issues, "p-aaaa").unwrap(),
1770 index.membership("p-aaaa").unwrap()
1771 );
1772 assert_eq!(
1773 validate_references(&config, &issues),
1774 index.missing().to_vec()
1775 );
1776 assert_eq!(
1777 list_projects(&config, &issues),
1778 index
1779 .projects()
1780 .into_iter()
1781 .cloned()
1782 .collect::<Vec<Project>>()
1783 );
1784 }
1785
1786 #[test]
1787 fn associate_leaves_the_registry_record_unchanged() {
1788 let config = registry(&[("p-aaaa", "One")]);
1794 let before = config.projects["p-aaaa"].clone();
1795 let issues = vec![Node::new(1, "One")];
1796 let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1797
1798 assert_eq!(config.projects["p-aaaa"], before);
1799 assert_eq!(
1800 config.projects.len(),
1801 1,
1802 "a project record carries no member list"
1803 );
1804 assert_eq!(change.project_ids, vec!["p-aaaa".to_string()]);
1805
1806 let serialized = toml::to_string(&config).expect("the registry serializes");
1807 assert!(
1808 !serialized.contains("member"),
1809 "no member/members key may reach disk: {serialized}"
1810 );
1811 }
1812
1813 #[test]
1816 fn associate_produces_the_list_and_one_event() {
1817 let config = registry(&[("p-aaaa", "One")]);
1818 let issues = vec![Node::new(1, "One")];
1819 let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1820 assert_eq!(ids(&change), ["p-aaaa"]);
1821 let event = change.event.expect("an accepted change is audited");
1822 assert_eq!(event.kind, ProjectEventKind::ProjectAssociated);
1823 assert_eq!(event.project_id, "p-aaaa");
1824 assert_eq!(event.detail, "project `p-aaaa` associated");
1825 }
1826
1827 #[test]
1828 fn re_association_is_idempotent_and_emits_no_event() {
1829 let config = registry(&[("p-aaaa", "One")]);
1830 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1831 let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1832 assert_eq!(ids(&change), ["p-aaaa"], "no duplicate entry");
1833 assert!(change.event.is_none(), "a no-op is not an audit entry");
1834 }
1835
1836 #[test]
1837 fn associating_an_unknown_project_or_issue_is_refused() {
1838 let config = registry(&[("p-aaaa", "One")]);
1839 let issues = vec![Node::new(1, "One")];
1840 let err = associate(&config, &issues, 1, "p-nope").unwrap_err();
1841 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1842 let err = associate(&config, &issues, 99, "p-aaaa").unwrap_err();
1843 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1844 assert!(err.message().contains("99"));
1845 }
1846
1847 #[test]
1848 fn dissociate_keeps_the_issue_intact() {
1849 let issues = vec![Node::new(1, "One").with(&["p-aaaa", "p-bbbb"])];
1850 let change = dissociate(&issues, 1, "p-aaaa").unwrap();
1851 assert_eq!(ids(&change), ["p-bbbb"], "only the one association drops");
1852 let event = change.event.unwrap();
1853 assert_eq!(event.kind, ProjectEventKind::ProjectDissociated);
1854 assert_eq!(event.detail, "project `p-aaaa` dissociated");
1855 assert_eq!(issues[0].projects, vec!["p-aaaa", "p-bbbb"]);
1858 }
1859
1860 #[test]
1861 fn dissociating_an_absent_association_is_a_noop() {
1862 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1863 let change = dissociate(&issues, 1, "p-bbbb").unwrap();
1864 assert_eq!(ids(&change), ["p-aaaa"]);
1865 assert!(change.event.is_none());
1866 }
1867
1868 #[test]
1869 fn dissociate_does_not_resolve_a_project_name() {
1870 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1874 let change = dissociate(&issues, 1, "One").unwrap();
1875 assert_eq!(ids(&change), ["p-aaaa"], "a name must not match the id");
1876 assert!(change.event.is_none(), "and it is not an audit entry");
1877 }
1878
1879 #[test]
1880 fn dissociate_removes_a_stale_reference_without_a_project() {
1881 let issues = vec![Node::new(1, "One").with(&["p-deleted"])];
1883 let change = dissociate(&issues, 1, "p-deleted").unwrap();
1884 assert_eq!(change.project_ids, [] as [std::string::String; 0]);
1885 assert!(change.event.is_some());
1886 }
1887
1888 #[test]
1889 fn dissociate_refuses_an_unknown_issue() {
1890 let issues = vec![Node::new(1, "One")];
1891 let err = dissociate(&issues, 42, "p-aaaa").unwrap_err();
1892 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1893 }
1894
1895 #[test]
1896 fn bulk_associate_reports_each_issue_independently() {
1897 let config = registry(&[("p-aaaa", "One")]);
1898 let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1899 let outcomes = bulk_associate(&config, &issues, "p-aaaa", &[1, 99, 2]);
1900
1901 assert_eq!(outcomes.len(), 3, "one entry per selector");
1902 assert_eq!(outcomes[0].selector, 1);
1903 assert!(outcomes[0].outcome.is_ok());
1904 assert_eq!(
1905 outcomes[1].outcome.as_ref().unwrap_err().kind(),
1906 ProjectDiagnosticKind::UnknownIssue,
1907 "the bad selector is reported, not fatal"
1908 );
1909 assert!(
1910 outcomes[2].outcome.is_ok(),
1911 "the good selectors after a bad one still process"
1912 );
1913 let events: usize = outcomes
1915 .iter()
1916 .filter_map(|o| o.outcome.as_ref().ok())
1917 .filter(|change| change.event.is_some())
1918 .count();
1919 assert_eq!(events, 2);
1920 }
1921
1922 #[test]
1923 fn bulk_associate_reports_an_unknown_project_per_selector() {
1924 let config = registry(&[("p-aaaa", "One")]);
1925 let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1926 let outcomes = bulk_associate(&config, &issues, "p-nope", &[1, 2]);
1927 assert!(outcomes.iter().all(|o| {
1928 o.outcome.as_ref().unwrap_err().kind() == ProjectDiagnosticKind::UnknownProject
1929 }));
1930 }
1931
1932 #[test]
1935 fn a_missing_reference_names_the_issue_and_the_id() {
1936 let config = registry(&[("p-aaaa", "One")]);
1937 let issues = vec![Node::new(4, "Four").with(&["p-aaaa", "p-gone"])];
1938 let missing = validate_references(&config, &issues);
1939 assert_eq!(missing.len(), 1);
1940 assert_eq!(missing[0].issue.number(), 4);
1941 assert_eq!(missing[0].issue_display, "#4 Four");
1942 assert_eq!(missing[0].project_id, "p-gone");
1943 assert!(missing[0].reason.contains("#4"));
1944 assert!(missing[0].reason.contains("p-gone"));
1945 }
1946
1947 #[test]
1948 fn a_resolvable_set_reports_no_missing_references() {
1949 let config = registry(&[("p-aaaa", "One")]);
1950 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1951 assert_eq!(
1952 validate_references(&config, &issues),
1953 [] as [crate::projects::MissingProjectReference; 0]
1954 );
1955 }
1956
1957 #[test]
1958 fn a_duplicated_missing_id_is_reported_once() {
1959 let config = Config::default();
1960 let issues = vec![Node::new(1, "One").with(&["p-gone", "p-gone"])];
1961 assert_eq!(validate_references(&config, &issues).len(), 1);
1962 }
1963
1964 #[test]
1965 fn a_malformed_id_is_a_missing_reference_not_a_panic() {
1966 let config = Config::default();
1967 let issues = vec![Node::new(1, "One").with(&["not a valid id"])];
1968 let missing = validate_references(&config, &issues);
1969 assert_eq!(missing.len(), 1);
1970 assert_eq!(missing[0].project_id, "not a valid id");
1971 }
1972
1973 #[test]
1974 fn validate_references_is_deterministic() {
1975 let config = registry(&[("p-aaaa", "One")]);
1976 let issues = vec![
1977 Node::new(3, "Three").with(&["p-z", "p-a"]),
1978 Node::new(1, "One").with(&["p-m"]),
1979 ];
1980 let first = validate_references(&config, &issues);
1981 let second = validate_references(&config, &issues);
1982 assert_eq!(first, second);
1983 assert_eq!(
1984 first
1985 .iter()
1986 .map(|m| (m.issue.number(), m.project_id.as_str()))
1987 .collect::<Vec<_>>(),
1988 vec![(1, "p-m"), (3, "p-a"), (3, "p-z")],
1989 "ordered by (issue number, id)"
1990 );
1991 }
1992
1993 #[test]
1994 fn a_missing_reference_is_accounted_for_in_the_index() {
1995 let config = Config::default();
1997 let issues = vec![Node::new(1, "One").with(&["p-gone"])];
1998 let index = ProjectIndex::build(&config, &issues);
1999 assert!(index.buckets().is_empty());
2000 assert_eq!(index.missing().len(), 1);
2001 }
2002
2003 #[test]
2006 fn the_operations_run_on_in_memory_values() {
2007 let mut config = Config::default();
2013 let created = create_project(
2014 &mut config,
2015 &NewProject {
2016 name: "In memory".into(),
2017 ..NewProject::default()
2018 },
2019 )
2020 .unwrap();
2021 let issues = vec![Node::new(1, "One")];
2022 let change = associate(&config, &issues, 1, created.id.as_str()).unwrap();
2023 assert_eq!(change.project_ids, vec![created.id.to_string()]);
2024 assert_eq!(list_projects(&config, &issues).len(), 1);
2025 assert_eq!(
2026 validate_references(&config, &issues),
2027 [] as [crate::projects::MissingProjectReference; 0]
2028 );
2029 }
2030
2031 #[test]
2034 fn t015_a_project_with_four_members_and_one_complete_reports_one_of_four() {
2035 let config = registry(&[("p-aaaa", "One")]);
2036 let issues = vec![
2037 Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2038 Node::new(2, "Two").with(&["p-aaaa"]),
2039 Node::new(3, "Three").with(&["p-aaaa"]),
2040 Node::new(4, "Four").with(&["p-aaaa"]),
2041 ];
2042 let assigned = assigned_keys(&issues);
2043 let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2044 assert_eq!(report.progress.done, 1);
2045 assert_eq!(report.progress.total, 4);
2046 assert_eq!(
2047 report.progress.progress,
2048 Progress { done: 1, total: 4 },
2049 "the typed planner value is reused, not restated"
2050 );
2051 assert_eq!(report.project_id.as_str(), "p-aaaa");
2052 assert_eq!(report.name, "One");
2053 }
2054
2055 #[test]
2056 fn t015_an_empty_project_reports_zeros() {
2057 let config = registry(&[("p-aaaa", "One")]);
2058 let report =
2059 health::<Node, _>(&config, &[], "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2060 assert_eq!(report.progress.progress, Progress { done: 0, total: 0 });
2061 assert_eq!(report.health.overdue, 0);
2062 assert_eq!(report.health.blocked, 0);
2063 assert_eq!(report.health.unassigned, 0);
2064 assert_eq!(
2065 report.health.missing_references,
2066 [] as [crate::projects::MissingProjectReference; 0]
2067 );
2068 }
2069
2070 #[test]
2071 fn t015_completion_resolves_from_the_configured_column_not_a_literal() {
2072 let mut config = custom_completion(
2076 &["triage", "wip", "shipped"],
2077 "shipped",
2078 &[
2079 ("triage", "unstarted"),
2080 ("wip", "active"),
2081 ("shipped", "completed"),
2082 ],
2083 );
2084 config.projects.insert(
2085 "p-aaaa".to_string(),
2086 ProjectConfiguration {
2087 name: "One".to_string(),
2088 ..ProjectConfiguration::default()
2089 },
2090 );
2091 let issues = vec![
2092 Node::new(1, "Shipped")
2093 .in_column("shipped")
2094 .with(&["p-aaaa"]),
2095 Node::new(2, "Literal done")
2096 .in_column("done")
2097 .with(&["p-aaaa"]),
2098 ];
2099 let report = health(&config, &issues, "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2100 assert_eq!(
2101 report.progress.done, 1,
2102 "only the configured completion column counts"
2103 );
2104 assert_eq!(report.progress.total, 2);
2105 }
2106
2107 #[test]
2108 fn t016_health_counts_the_known_fixture_exactly() {
2109 let config = registry(&[("p-aaaa", "One")]);
2110 let issues = vec![
2111 Node::new(1, "Complete")
2112 .in_column("done")
2113 .assigned_to("alice")
2114 .with(&["p-aaaa"]),
2115 Node::new(2, "Overdue")
2116 .assigned_to("bob")
2117 .with(&["p-aaaa", "p-gone"]),
2118 Node::new(3, "Blocked")
2119 .assigned_to("carol")
2120 .with(&["p-aaaa"]),
2121 Node::new(4, "Unassigned").with(&["p-aaaa"]),
2122 ];
2123 let assigned = assigned_keys(&issues);
2124 let report = health(
2126 &config,
2127 &issues,
2128 "p-aaaa",
2129 &assigned,
2130 &set(&[1, 2]),
2131 &set(&[3]),
2132 )
2133 .unwrap();
2134 assert_eq!(report.progress.done, 1);
2135 assert_eq!(report.progress.total, 4);
2136 assert_eq!(report.health.overdue, 1, "a done issue is not overdue");
2137 assert_eq!(report.health.blocked, 1);
2138 assert_eq!(report.health.unassigned, 1);
2139 assert_eq!(report.health.missing_references.len(), 1);
2140 let missing = &report.health.missing_references[0];
2141 assert_eq!(missing.issue.number(), 2);
2142 assert_eq!(missing.project_id, "p-gone");
2143 assert_ne!(missing.reason, "");
2144 }
2145
2146 #[test]
2147 fn t016_a_done_member_is_never_overdue() {
2148 let config = registry(&[("p-aaaa", "One")]);
2149 let issues = vec![
2150 Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2151 Node::new(2, "Open").with(&["p-aaaa"]),
2152 ];
2153 let report = health(
2154 &config,
2155 &issues,
2156 "p-aaaa",
2157 &set(&[1, 2]),
2158 &set(&[1, 2]),
2159 &set(&[]),
2160 )
2161 .unwrap();
2162 assert_eq!(
2163 report.health.overdue, 1,
2164 "only the open overdue member counts"
2165 );
2166 }
2167
2168 #[test]
2169 fn t016_a_blank_assignee_counts_as_unassigned() {
2170 let config = registry(&[("p-aaaa", "One")]);
2171 let issues = vec![
2172 Node::new(1, "Absent").with(&["p-aaaa"]),
2173 Node::new(2, "Blank").assigned_to(" ").with(&["p-aaaa"]),
2174 Node::new(3, "Assigned")
2175 .assigned_to("alice")
2176 .with(&["p-aaaa"]),
2177 ];
2178 let assigned = assigned_keys(&issues);
2179 assert_eq!(assigned, set(&[3]), "absent and blank are both unassigned");
2180 let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2181 assert_eq!(report.health.unassigned, 2);
2182 }
2183
2184 #[test]
2185 fn t016_a_resolvable_member_set_reports_no_missing_references() {
2186 let config = registry(&[("p-aaaa", "One")]);
2187 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
2188 let report = health(&config, &issues, "p-aaaa", &set(&[1]), &set(&[]), &set(&[])).unwrap();
2189 assert_eq!(
2190 report.health.missing_references,
2191 [] as [crate::projects::MissingProjectReference; 0]
2192 );
2193 }
2194
2195 #[test]
2196 fn t016_health_refuses_an_unknown_project() {
2197 let config = registry(&[("p-aaaa", "One")]);
2198 let err =
2199 health::<Node, _>(&config, &[], "p-nope", &set(&[]), &set(&[]), &set(&[])).unwrap_err();
2200 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
2201 }
2202
2203 #[test]
2206 fn t019_c006_the_health_read_is_deterministic_and_correct() {
2207 let config = registry(&[("p-aaaa", "One")]);
2211 let issues = vec![
2212 Node::new(1, "Complete")
2213 .in_column("done")
2214 .assigned_to("alice")
2215 .with(&["p-aaaa"]),
2216 Node::new(2, "Overdue").assigned_to("bob").with(&["p-aaaa"]),
2217 Node::new(3, "Blocked")
2218 .assigned_to("carol")
2219 .with(&["p-aaaa"]),
2220 Node::new(4, "Unassigned").with(&["p-aaaa"]),
2221 ];
2222 let assigned = assigned_keys(&issues);
2223 let first = health(
2224 &config,
2225 &issues,
2226 "p-aaaa",
2227 &assigned,
2228 &set(&[2]),
2229 &set(&[3]),
2230 )
2231 .unwrap();
2232 assert_eq!(
2233 first.progress,
2234 ProjectProgress {
2235 done: 1,
2236 total: 4,
2237 progress: Progress { done: 1, total: 4 },
2238 }
2239 );
2240 assert_eq!(first.health.overdue, 1);
2241 assert_eq!(first.health.blocked, 1);
2242 assert_eq!(first.health.unassigned, 1);
2243 for _ in 0..3 {
2244 let again = health(
2245 &config,
2246 &issues,
2247 "p-aaaa",
2248 &assigned,
2249 &set(&[2]),
2250 &set(&[3]),
2251 )
2252 .unwrap();
2253 assert_eq!(again, first, "repeated reads of one snapshot are identical");
2254 }
2255 }
2256
2257 #[test]
2258 fn t019_c006_a_noop_association_change_leaves_the_figures_unchanged() {
2259 let config = registry(&[("p-aaaa", "One")]);
2266 let issues = vec![
2267 Node::new(1, "Complete").in_column("done").with(&["p-aaaa"]),
2268 Node::new(2, "Open").with(&["p-aaaa"]),
2269 ];
2270 let assigned = assigned_keys(&issues);
2271 let before = health(&config, &issues, "p-aaaa", &assigned, &set(&[2]), &set(&[])).unwrap();
2272
2273 let change = dissociate(&issues, 1, "p-bbbb").unwrap();
2274 assert_eq!(ids(&change), ["p-aaaa"], "the list is unchanged");
2275 assert!(change.event.is_none(), "no event is emitted");
2276
2277 let mut applied = issues.clone();
2278 applied[0].projects = change.project_ids.clone();
2279 let assigned_after = assigned_keys(&applied);
2280 let after = health(
2281 &config,
2282 &applied,
2283 "p-aaaa",
2284 &assigned_after,
2285 &set(&[2]),
2286 &set(&[]),
2287 )
2288 .unwrap();
2289 assert_eq!(
2290 before, after,
2291 "applying the no-op leaves the figures unchanged"
2292 );
2293 }
2294}