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 use super::*;
1353
1354 #[derive(Debug, Clone, PartialEq, Eq)]
1360 struct Node {
1361 number: u32,
1362 title: String,
1363 status: String,
1364 projects: Vec<String>,
1365 assignee: Option<String>,
1366 }
1367
1368 impl Node {
1369 fn new(number: u32, title: &str) -> Self {
1370 Self {
1371 number,
1372 title: title.to_string(),
1373 status: "todo".to_string(),
1374 projects: Vec::new(),
1375 assignee: None,
1376 }
1377 }
1378
1379 fn with(mut self, ids: &[&str]) -> Self {
1380 self.projects = ids.iter().map(|id| (*id).to_string()).collect();
1381 self
1382 }
1383
1384 fn in_column(mut self, status: &str) -> Self {
1385 self.status = status.to_string();
1386 self
1387 }
1388
1389 fn assigned_to(mut self, assignee: &str) -> Self {
1390 self.assignee = Some(assignee.to_string());
1391 self
1392 }
1393 }
1394
1395 impl ProjectNode for Node {
1396 fn number(&self) -> u32 {
1397 self.number
1398 }
1399 fn title(&self) -> &str {
1400 &self.title
1401 }
1402 fn status(&self) -> &str {
1403 &self.status
1404 }
1405 fn project_ids(&self) -> &[String] {
1406 &self.projects
1407 }
1408 }
1409
1410 impl HealthNode for Node {
1411 fn assignee(&self) -> Option<&str> {
1412 self.assignee.as_deref()
1413 }
1414 }
1415
1416 fn registry(entries: &[(&str, &str)]) -> Config {
1418 let mut config = Config::default();
1419 for (id, name) in entries {
1420 config.projects.insert(
1421 (*id).to_string(),
1422 ProjectConfiguration {
1423 name: (*name).to_string(),
1424 ..ProjectConfiguration::default()
1425 },
1426 );
1427 }
1428 config
1429 }
1430
1431 fn ids(change: &ProjectChange) -> Vec<&str> {
1432 change.project_ids.iter().map(String::as_str).collect()
1433 }
1434
1435 fn set(numbers: &[u32]) -> HashSet<u32> {
1437 numbers.iter().copied().collect()
1438 }
1439
1440 fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
1443 let mut config = Config::default();
1444 config.kanban.configurations.insert(
1445 "default".to_string(),
1446 crate::config::BoardConfiguration {
1447 columns: columns.iter().map(|c| (*c).to_string()).collect(),
1448 done_column: Some(done_column.to_string()),
1449 category_map: map
1450 .iter()
1451 .map(|(column, key)| ((*column).to_string(), (*key).to_string()))
1452 .collect(),
1453 ..Default::default()
1454 },
1455 );
1456 config
1457 }
1458
1459 #[test]
1462 fn create_project_allocates_a_stable_id() {
1463 let mut config = Config::default();
1464 let project = create_project(
1465 &mut config,
1466 &NewProject {
1467 name: "Platform hardening".into(),
1468 description: Some("reduce risk".into()),
1469 ..NewProject::default()
1470 },
1471 )
1472 .unwrap();
1473
1474 assert!(project.id.as_str().starts_with("p-"));
1475 assert_eq!(project.name, "Platform hardening");
1476 assert_eq!(project.member_count, 0);
1477 assert!(config.projects.contains_key(project.id.as_str()));
1478 assert_eq!(
1480 config.projects[project.id.as_str()].name,
1481 "Platform hardening"
1482 );
1483 }
1484
1485 #[test]
1486 fn create_project_refuses_a_duplicate_name_case_folded() {
1487 let mut config = registry(&[("p-aaaa", "Platform")]);
1488 let err = create_project(
1489 &mut config,
1490 &NewProject {
1491 name: "platform".into(),
1492 ..NewProject::default()
1493 },
1494 )
1495 .unwrap_err();
1496 assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
1497 assert_eq!(config.projects.len(), 1, "a refusal writes nothing");
1498 }
1499
1500 #[test]
1501 fn create_project_refuses_inverted_dates() {
1502 let mut config = Config::default();
1503 let err = create_project(
1504 &mut config,
1505 &NewProject {
1506 name: "Bad dates".into(),
1507 start_date: Some("2026-12-01".into()),
1508 due_date: Some("2026-01-01".into()),
1509 ..NewProject::default()
1510 },
1511 )
1512 .unwrap_err();
1513 assert_eq!(err.kind(), ProjectDiagnosticKind::InvalidDates);
1514 assert!(config.projects.is_empty());
1515 }
1516
1517 #[test]
1518 fn rename_changes_the_label_only() {
1519 let mut config = registry(&[("p-3f9a2c", "Platform hardening")]);
1520 let issues = vec![Node::new(1, "One").with(&["p-3f9a2c"])];
1521
1522 let edited = edit_project(
1523 &mut config,
1524 "p-3f9a2c",
1525 &ProjectEdit {
1526 name: Patch::Set("Platform".into()),
1527 ..ProjectEdit::default()
1528 },
1529 )
1530 .unwrap();
1531
1532 assert!(config.projects.contains_key("p-3f9a2c"));
1534 assert_eq!(edited.id.as_str(), "p-3f9a2c");
1535 assert_eq!(edited.name, "Platform");
1536 let membership = membership(&config, &issues, "p-3f9a2c").unwrap();
1537 assert_eq!(membership.member_count, 1);
1538 }
1539
1540 #[test]
1541 fn edit_project_clears_an_optional_field() {
1542 let mut config = registry(&[("p-aaaa", "One")]);
1543 config.projects["p-aaaa"].lead = Some("Alice".into());
1544 let edited = edit_project(
1545 &mut config,
1546 "p-aaaa",
1547 &ProjectEdit {
1548 lead: Patch::Clear,
1549 ..ProjectEdit::default()
1550 },
1551 )
1552 .unwrap();
1553 assert_eq!(edited.lead, None);
1554 assert_eq!(config.projects["p-aaaa"].lead, None);
1555 }
1556
1557 #[test]
1558 fn edit_project_refuses_an_unknown_reference() {
1559 let mut config = registry(&[("p-aaaa", "One")]);
1560 let err = edit_project(
1561 &mut config,
1562 "does-not-exist",
1563 &ProjectEdit {
1564 name: Patch::Set("X".into()),
1565 ..ProjectEdit::default()
1566 },
1567 )
1568 .unwrap_err();
1569 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1570 }
1571
1572 #[test]
1573 fn delete_reports_affected_issues_and_keeps_them() {
1574 let mut config = registry(&[("p-aaaa", "One")]);
1575 let issues = vec![
1576 Node::new(2, "Two").with(&["p-aaaa"]),
1577 Node::new(7, "Seven").with(&["p-aaaa"]),
1578 ];
1579
1580 let impact = delete_project(&mut config, "One", &issues).unwrap();
1581
1582 assert_eq!(impact.id.as_str(), "p-aaaa");
1583 assert_eq!(impact.name, "One");
1584 assert_eq!(
1585 impact
1586 .affected
1587 .iter()
1588 .map(|m| m.issue.number())
1589 .collect::<Vec<_>>(),
1590 vec![2, 7],
1591 "affected issues are ordered by issue number"
1592 );
1593 assert!(config.projects.is_empty());
1595 assert_eq!(issues[0].projects, vec!["p-aaaa".to_string()]);
1596 assert_eq!(validate_references(&config, &issues).len(), 2);
1598 }
1599
1600 #[test]
1601 fn successive_create_draws_after_a_delete_are_distinct() {
1602 let mut config = Config::default();
1609 let first = create_project(
1610 &mut config,
1611 &NewProject {
1612 name: "One".into(),
1613 ..NewProject::default()
1614 },
1615 )
1616 .unwrap();
1617 delete_project(&mut config, first.id.as_str(), &[] as &[Node]).unwrap();
1618 let second = create_project(
1619 &mut config,
1620 &NewProject {
1621 name: "One".into(),
1622 ..NewProject::default()
1623 },
1624 )
1625 .unwrap();
1626 assert_ne!(first.id, second.id, "two draws must be distinct");
1627 }
1628
1629 #[test]
1630 fn resolve_by_id_and_by_name_agree() {
1631 let config = registry(&[("p-aaaa", "Platform"), ("p-bbbb", "Docs")]);
1632 let issues = vec![Node::new(1, "One").with(&["p-bbbb"])];
1633 let by_id = resolve(&config, &issues, "p-bbbb").unwrap();
1634 let by_name = resolve(&config, &issues, "Docs").unwrap();
1635 assert_eq!(by_id, by_name);
1636 assert_eq!(by_id.member_count, 1);
1637 }
1638
1639 #[test]
1640 fn id_resolution_takes_precedence_over_a_name_lookalike() {
1641 let mut config = registry(&[("p-aaaa", "Platform"), ("Docs", "Docs refresh")]);
1642 config.projects["Docs"].name = "Docs refresh".into();
1644 let resolved = resolve_project_id(&config, "Docs").unwrap();
1645 assert_eq!(resolved.as_str(), "Docs", "the id is matched first");
1646 }
1647
1648 #[test]
1649 fn unknown_id_and_unknown_name_are_refused() {
1650 let config = registry(&[("p-aaaa", "Platform")]);
1651 for reference in ["p-missing", "No such name"] {
1652 let err = resolve_project_id(&config, reference).unwrap_err();
1653 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1654 assert!(err.message().contains(reference));
1655 }
1656 }
1657
1658 #[test]
1659 fn list_projects_is_declaration_ordered_with_member_counts() {
1660 let config = registry(&[("p-zeta", "Zeta"), ("p-alpha", "Alpha")]);
1661 let issues = vec![
1662 Node::new(3, "Three").with(&["p-alpha"]),
1663 Node::new(1, "One").with(&["p-zeta", "p-alpha"]),
1664 ];
1665 let projects = list_projects(&config, &issues);
1666 assert_eq!(
1667 projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
1668 ["Zeta", "Alpha"],
1669 "declaration order is the display order"
1670 );
1671 assert_eq!(projects[0].member_count, 1);
1672 assert_eq!(projects[1].member_count, 2);
1673 }
1674
1675 #[test]
1676 fn list_projects_never_errors_on_an_empty_registry() {
1677 let config = Config::default();
1678 assert_eq!(
1679 list_projects(&config, &[] as &[Node]),
1680 [] as [crate::projects::Project; 0]
1681 );
1682 }
1683
1684 #[test]
1685 fn project_display_shows_the_id_beside_the_name() {
1686 let id = ProjectId::new("p-3f9a2c").unwrap();
1687 assert_eq!(project_display(&id, "Platform"), "Platform (p-3f9a2c)");
1688 }
1689
1690 #[test]
1693 fn an_issue_may_belong_to_several_projects() {
1694 let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1695 let issues = vec![Node::new(1, "Shared").with(&["p-aaaa", "p-bbbb"])];
1696 assert_eq!(
1697 membership(&config, &issues, "p-aaaa").unwrap().member_count,
1698 1
1699 );
1700 assert_eq!(
1701 membership(&config, &issues, "p-bbbb").unwrap().member_count,
1702 1
1703 );
1704 }
1705
1706 #[test]
1707 fn an_unassociated_issue_is_in_no_bucket() {
1708 let config = registry(&[("p-aaaa", "One")]);
1709 let issues = vec![Node::new(1, "None"), Node::new(2, "One").with(&["p-aaaa"])];
1710 let index = ProjectIndex::build(&config, &issues);
1711 let id = ProjectId::new("p-aaaa").unwrap();
1712 assert_eq!(index.bucket(&id).unwrap(), [IssueKey::new(2)]);
1713 }
1714
1715 #[test]
1716 fn bucket_is_none_only_for_an_unknown_project() {
1717 let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1720 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1721 let index = ProjectIndex::build(&config, &issues);
1722
1723 let known_empty = ProjectId::new("p-bbbb").unwrap();
1724 assert_eq!(index.bucket(&known_empty), Some(&[][..]));
1725
1726 let unknown = ProjectId::new("p-missing").unwrap();
1727 assert_eq!(index.bucket(&unknown), None);
1728 }
1729
1730 #[test]
1731 fn membership_order_is_stable_across_repeated_builds() {
1732 let config = registry(&[("p-aaaa", "One")]);
1733 let issues = vec![
1735 Node::new(9, "Nine").with(&["p-aaaa"]),
1736 Node::new(2, "Two").with(&["p-aaaa"]),
1737 Node::new(5, "Five").with(&["p-aaaa"]),
1738 ];
1739 let first = membership(&config, &issues, "p-aaaa").unwrap();
1740 let second = membership(&config, &issues, "p-aaaa").unwrap();
1741 assert_eq!(first, second);
1742 assert_eq!(
1743 first
1744 .members
1745 .iter()
1746 .map(|m| m.issue.number())
1747 .collect::<Vec<_>>(),
1748 vec![2, 5, 9]
1749 );
1750 assert_eq!(first.members[0].display, "#2 Two");
1751 }
1752
1753 #[test]
1754 fn the_free_operations_agree_with_one_built_index() {
1755 let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1760 let issues = vec![
1761 Node::new(1, "One").with(&["p-aaaa", "p-bbbb"]),
1762 Node::new(2, "Two").with(&["p-aaaa"]),
1763 ];
1764 let index = ProjectIndex::build(&config, &issues);
1765
1766 assert_eq!(membership_index(&config, &issues), *index.buckets());
1767 assert_eq!(
1768 membership(&config, &issues, "p-aaaa").unwrap(),
1769 index.membership("p-aaaa").unwrap()
1770 );
1771 assert_eq!(
1772 validate_references(&config, &issues),
1773 index.missing().to_vec()
1774 );
1775 assert_eq!(
1776 list_projects(&config, &issues),
1777 index
1778 .projects()
1779 .into_iter()
1780 .cloned()
1781 .collect::<Vec<Project>>()
1782 );
1783 }
1784
1785 #[test]
1786 fn associate_leaves_the_registry_record_unchanged() {
1787 let config = registry(&[("p-aaaa", "One")]);
1793 let before = config.projects["p-aaaa"].clone();
1794 let issues = vec![Node::new(1, "One")];
1795 let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1796
1797 assert_eq!(config.projects["p-aaaa"], before);
1798 assert_eq!(
1799 config.projects.len(),
1800 1,
1801 "a project record carries no member list"
1802 );
1803 assert_eq!(change.project_ids, vec!["p-aaaa".to_string()]);
1804
1805 let serialized = toml::to_string(&config).expect("the registry serializes");
1806 assert!(
1807 !serialized.contains("member"),
1808 "no member/members key may reach disk: {serialized}"
1809 );
1810 }
1811
1812 #[test]
1815 fn associate_produces_the_list_and_one_event() {
1816 let config = registry(&[("p-aaaa", "One")]);
1817 let issues = vec![Node::new(1, "One")];
1818 let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1819 assert_eq!(ids(&change), ["p-aaaa"]);
1820 let event = change.event.expect("an accepted change is audited");
1821 assert_eq!(event.kind, ProjectEventKind::ProjectAssociated);
1822 assert_eq!(event.project_id, "p-aaaa");
1823 assert_eq!(event.detail, "project `p-aaaa` associated");
1824 }
1825
1826 #[test]
1827 fn re_association_is_idempotent_and_emits_no_event() {
1828 let config = registry(&[("p-aaaa", "One")]);
1829 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1830 let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1831 assert_eq!(ids(&change), ["p-aaaa"], "no duplicate entry");
1832 assert!(change.event.is_none(), "a no-op is not an audit entry");
1833 }
1834
1835 #[test]
1836 fn associating_an_unknown_project_or_issue_is_refused() {
1837 let config = registry(&[("p-aaaa", "One")]);
1838 let issues = vec![Node::new(1, "One")];
1839 let err = associate(&config, &issues, 1, "p-nope").unwrap_err();
1840 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1841 let err = associate(&config, &issues, 99, "p-aaaa").unwrap_err();
1842 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1843 assert!(err.message().contains("99"));
1844 }
1845
1846 #[test]
1847 fn dissociate_keeps_the_issue_intact() {
1848 let issues = vec![Node::new(1, "One").with(&["p-aaaa", "p-bbbb"])];
1849 let change = dissociate(&issues, 1, "p-aaaa").unwrap();
1850 assert_eq!(ids(&change), ["p-bbbb"], "only the one association drops");
1851 let event = change.event.unwrap();
1852 assert_eq!(event.kind, ProjectEventKind::ProjectDissociated);
1853 assert_eq!(event.detail, "project `p-aaaa` dissociated");
1854 assert_eq!(issues[0].projects, vec!["p-aaaa", "p-bbbb"]);
1857 }
1858
1859 #[test]
1860 fn dissociating_an_absent_association_is_a_noop() {
1861 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1862 let change = dissociate(&issues, 1, "p-bbbb").unwrap();
1863 assert_eq!(ids(&change), ["p-aaaa"]);
1864 assert!(change.event.is_none());
1865 }
1866
1867 #[test]
1868 fn dissociate_does_not_resolve_a_project_name() {
1869 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1873 let change = dissociate(&issues, 1, "One").unwrap();
1874 assert_eq!(ids(&change), ["p-aaaa"], "a name must not match the id");
1875 assert!(change.event.is_none(), "and it is not an audit entry");
1876 }
1877
1878 #[test]
1879 fn dissociate_removes_a_stale_reference_without_a_project() {
1880 let issues = vec![Node::new(1, "One").with(&["p-deleted"])];
1882 let change = dissociate(&issues, 1, "p-deleted").unwrap();
1883 assert_eq!(change.project_ids, [] as [std::string::String; 0]);
1884 assert!(change.event.is_some());
1885 }
1886
1887 #[test]
1888 fn dissociate_refuses_an_unknown_issue() {
1889 let issues = vec![Node::new(1, "One")];
1890 let err = dissociate(&issues, 42, "p-aaaa").unwrap_err();
1891 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1892 }
1893
1894 #[test]
1895 fn bulk_associate_reports_each_issue_independently() {
1896 let config = registry(&[("p-aaaa", "One")]);
1897 let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1898 let outcomes = bulk_associate(&config, &issues, "p-aaaa", &[1, 99, 2]);
1899
1900 assert_eq!(outcomes.len(), 3, "one entry per selector");
1901 assert_eq!(outcomes[0].selector, 1);
1902 assert!(outcomes[0].outcome.is_ok());
1903 assert_eq!(
1904 outcomes[1].outcome.as_ref().unwrap_err().kind(),
1905 ProjectDiagnosticKind::UnknownIssue,
1906 "the bad selector is reported, not fatal"
1907 );
1908 assert!(
1909 outcomes[2].outcome.is_ok(),
1910 "the good selectors after a bad one still process"
1911 );
1912 let events: usize = outcomes
1914 .iter()
1915 .filter_map(|o| o.outcome.as_ref().ok())
1916 .filter(|change| change.event.is_some())
1917 .count();
1918 assert_eq!(events, 2);
1919 }
1920
1921 #[test]
1922 fn bulk_associate_reports_an_unknown_project_per_selector() {
1923 let config = registry(&[("p-aaaa", "One")]);
1924 let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1925 let outcomes = bulk_associate(&config, &issues, "p-nope", &[1, 2]);
1926 assert!(outcomes.iter().all(|o| {
1927 o.outcome.as_ref().unwrap_err().kind() == ProjectDiagnosticKind::UnknownProject
1928 }));
1929 }
1930
1931 #[test]
1934 fn a_missing_reference_names_the_issue_and_the_id() {
1935 let config = registry(&[("p-aaaa", "One")]);
1936 let issues = vec![Node::new(4, "Four").with(&["p-aaaa", "p-gone"])];
1937 let missing = validate_references(&config, &issues);
1938 assert_eq!(missing.len(), 1);
1939 assert_eq!(missing[0].issue.number(), 4);
1940 assert_eq!(missing[0].issue_display, "#4 Four");
1941 assert_eq!(missing[0].project_id, "p-gone");
1942 assert!(missing[0].reason.contains("#4"));
1943 assert!(missing[0].reason.contains("p-gone"));
1944 }
1945
1946 #[test]
1947 fn a_resolvable_set_reports_no_missing_references() {
1948 let config = registry(&[("p-aaaa", "One")]);
1949 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1950 assert_eq!(
1951 validate_references(&config, &issues),
1952 [] as [crate::projects::MissingProjectReference; 0]
1953 );
1954 }
1955
1956 #[test]
1957 fn a_duplicated_missing_id_is_reported_once() {
1958 let config = Config::default();
1959 let issues = vec![Node::new(1, "One").with(&["p-gone", "p-gone"])];
1960 assert_eq!(validate_references(&config, &issues).len(), 1);
1961 }
1962
1963 #[test]
1964 fn a_malformed_id_is_a_missing_reference_not_a_panic() {
1965 let config = Config::default();
1966 let issues = vec![Node::new(1, "One").with(&["not a valid id"])];
1967 let missing = validate_references(&config, &issues);
1968 assert_eq!(missing.len(), 1);
1969 assert_eq!(missing[0].project_id, "not a valid id");
1970 }
1971
1972 #[test]
1973 fn validate_references_is_deterministic() {
1974 let config = registry(&[("p-aaaa", "One")]);
1975 let issues = vec![
1976 Node::new(3, "Three").with(&["p-z", "p-a"]),
1977 Node::new(1, "One").with(&["p-m"]),
1978 ];
1979 let first = validate_references(&config, &issues);
1980 let second = validate_references(&config, &issues);
1981 assert_eq!(first, second);
1982 assert_eq!(
1983 first
1984 .iter()
1985 .map(|m| (m.issue.number(), m.project_id.as_str()))
1986 .collect::<Vec<_>>(),
1987 vec![(1, "p-m"), (3, "p-a"), (3, "p-z")],
1988 "ordered by (issue number, id)"
1989 );
1990 }
1991
1992 #[test]
1993 fn a_missing_reference_is_accounted_for_in_the_index() {
1994 let config = Config::default();
1996 let issues = vec![Node::new(1, "One").with(&["p-gone"])];
1997 let index = ProjectIndex::build(&config, &issues);
1998 assert!(index.buckets().is_empty());
1999 assert_eq!(index.missing().len(), 1);
2000 }
2001
2002 #[test]
2005 fn the_operations_run_on_in_memory_values() {
2006 let mut config = Config::default();
2012 let created = create_project(
2013 &mut config,
2014 &NewProject {
2015 name: "In memory".into(),
2016 ..NewProject::default()
2017 },
2018 )
2019 .unwrap();
2020 let issues = vec![Node::new(1, "One")];
2021 let change = associate(&config, &issues, 1, created.id.as_str()).unwrap();
2022 assert_eq!(change.project_ids, vec![created.id.to_string()]);
2023 assert_eq!(list_projects(&config, &issues).len(), 1);
2024 assert_eq!(
2025 validate_references(&config, &issues),
2026 [] as [crate::projects::MissingProjectReference; 0]
2027 );
2028 }
2029
2030 #[test]
2033 fn t015_a_project_with_four_members_and_one_complete_reports_one_of_four() {
2034 let config = registry(&[("p-aaaa", "One")]);
2035 let issues = vec![
2036 Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2037 Node::new(2, "Two").with(&["p-aaaa"]),
2038 Node::new(3, "Three").with(&["p-aaaa"]),
2039 Node::new(4, "Four").with(&["p-aaaa"]),
2040 ];
2041 let assigned = assigned_keys(&issues);
2042 let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2043 assert_eq!(report.progress.done, 1);
2044 assert_eq!(report.progress.total, 4);
2045 assert_eq!(
2046 report.progress.progress,
2047 Progress { done: 1, total: 4 },
2048 "the typed planner value is reused, not restated"
2049 );
2050 assert_eq!(report.project_id.as_str(), "p-aaaa");
2051 assert_eq!(report.name, "One");
2052 }
2053
2054 #[test]
2055 fn t015_an_empty_project_reports_zeros() {
2056 let config = registry(&[("p-aaaa", "One")]);
2057 let report =
2058 health::<Node, _>(&config, &[], "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2059 assert_eq!(report.progress.progress, Progress { done: 0, total: 0 });
2060 assert_eq!(report.health.overdue, 0);
2061 assert_eq!(report.health.blocked, 0);
2062 assert_eq!(report.health.unassigned, 0);
2063 assert_eq!(
2064 report.health.missing_references,
2065 [] as [crate::projects::MissingProjectReference; 0]
2066 );
2067 }
2068
2069 #[test]
2070 fn t015_completion_resolves_from_the_configured_column_not_a_literal() {
2071 let mut config = custom_completion(
2075 &["triage", "wip", "shipped"],
2076 "shipped",
2077 &[
2078 ("triage", "unstarted"),
2079 ("wip", "active"),
2080 ("shipped", "completed"),
2081 ],
2082 );
2083 config.projects.insert(
2084 "p-aaaa".to_string(),
2085 ProjectConfiguration {
2086 name: "One".to_string(),
2087 ..ProjectConfiguration::default()
2088 },
2089 );
2090 let issues = vec![
2091 Node::new(1, "Shipped")
2092 .in_column("shipped")
2093 .with(&["p-aaaa"]),
2094 Node::new(2, "Literal done")
2095 .in_column("done")
2096 .with(&["p-aaaa"]),
2097 ];
2098 let report = health(&config, &issues, "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2099 assert_eq!(
2100 report.progress.done, 1,
2101 "only the configured completion column counts"
2102 );
2103 assert_eq!(report.progress.total, 2);
2104 }
2105
2106 #[test]
2107 fn t016_health_counts_the_known_fixture_exactly() {
2108 let config = registry(&[("p-aaaa", "One")]);
2109 let issues = vec![
2110 Node::new(1, "Complete")
2111 .in_column("done")
2112 .assigned_to("alice")
2113 .with(&["p-aaaa"]),
2114 Node::new(2, "Overdue")
2115 .assigned_to("bob")
2116 .with(&["p-aaaa", "p-gone"]),
2117 Node::new(3, "Blocked")
2118 .assigned_to("carol")
2119 .with(&["p-aaaa"]),
2120 Node::new(4, "Unassigned").with(&["p-aaaa"]),
2121 ];
2122 let assigned = assigned_keys(&issues);
2123 let report = health(
2125 &config,
2126 &issues,
2127 "p-aaaa",
2128 &assigned,
2129 &set(&[1, 2]),
2130 &set(&[3]),
2131 )
2132 .unwrap();
2133 assert_eq!(report.progress.done, 1);
2134 assert_eq!(report.progress.total, 4);
2135 assert_eq!(report.health.overdue, 1, "a done issue is not overdue");
2136 assert_eq!(report.health.blocked, 1);
2137 assert_eq!(report.health.unassigned, 1);
2138 assert_eq!(report.health.missing_references.len(), 1);
2139 let missing = &report.health.missing_references[0];
2140 assert_eq!(missing.issue.number(), 2);
2141 assert_eq!(missing.project_id, "p-gone");
2142 assert_ne!(missing.reason, "");
2143 }
2144
2145 #[test]
2146 fn t016_a_done_member_is_never_overdue() {
2147 let config = registry(&[("p-aaaa", "One")]);
2148 let issues = vec![
2149 Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2150 Node::new(2, "Open").with(&["p-aaaa"]),
2151 ];
2152 let report = health(
2153 &config,
2154 &issues,
2155 "p-aaaa",
2156 &set(&[1, 2]),
2157 &set(&[1, 2]),
2158 &set(&[]),
2159 )
2160 .unwrap();
2161 assert_eq!(
2162 report.health.overdue, 1,
2163 "only the open overdue member counts"
2164 );
2165 }
2166
2167 #[test]
2168 fn t016_a_blank_assignee_counts_as_unassigned() {
2169 let config = registry(&[("p-aaaa", "One")]);
2170 let issues = vec![
2171 Node::new(1, "Absent").with(&["p-aaaa"]),
2172 Node::new(2, "Blank").assigned_to(" ").with(&["p-aaaa"]),
2173 Node::new(3, "Assigned")
2174 .assigned_to("alice")
2175 .with(&["p-aaaa"]),
2176 ];
2177 let assigned = assigned_keys(&issues);
2178 assert_eq!(assigned, set(&[3]), "absent and blank are both unassigned");
2179 let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2180 assert_eq!(report.health.unassigned, 2);
2181 }
2182
2183 #[test]
2184 fn t016_a_resolvable_member_set_reports_no_missing_references() {
2185 let config = registry(&[("p-aaaa", "One")]);
2186 let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
2187 let report = health(&config, &issues, "p-aaaa", &set(&[1]), &set(&[]), &set(&[])).unwrap();
2188 assert_eq!(
2189 report.health.missing_references,
2190 [] as [crate::projects::MissingProjectReference; 0]
2191 );
2192 }
2193
2194 #[test]
2195 fn t016_health_refuses_an_unknown_project() {
2196 let config = registry(&[("p-aaaa", "One")]);
2197 let err =
2198 health::<Node, _>(&config, &[], "p-nope", &set(&[]), &set(&[]), &set(&[])).unwrap_err();
2199 assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
2200 }
2201
2202 #[test]
2205 fn t019_c006_the_health_read_is_deterministic_and_correct() {
2206 let config = registry(&[("p-aaaa", "One")]);
2210 let issues = vec![
2211 Node::new(1, "Complete")
2212 .in_column("done")
2213 .assigned_to("alice")
2214 .with(&["p-aaaa"]),
2215 Node::new(2, "Overdue").assigned_to("bob").with(&["p-aaaa"]),
2216 Node::new(3, "Blocked")
2217 .assigned_to("carol")
2218 .with(&["p-aaaa"]),
2219 Node::new(4, "Unassigned").with(&["p-aaaa"]),
2220 ];
2221 let assigned = assigned_keys(&issues);
2222 let first = health(
2223 &config,
2224 &issues,
2225 "p-aaaa",
2226 &assigned,
2227 &set(&[2]),
2228 &set(&[3]),
2229 )
2230 .unwrap();
2231 assert_eq!(
2232 first.progress,
2233 ProjectProgress {
2234 done: 1,
2235 total: 4,
2236 progress: Progress { done: 1, total: 4 },
2237 }
2238 );
2239 assert_eq!(first.health.overdue, 1);
2240 assert_eq!(first.health.blocked, 1);
2241 assert_eq!(first.health.unassigned, 1);
2242 for _ in 0..3 {
2243 let again = health(
2244 &config,
2245 &issues,
2246 "p-aaaa",
2247 &assigned,
2248 &set(&[2]),
2249 &set(&[3]),
2250 )
2251 .unwrap();
2252 assert_eq!(again, first, "repeated reads of one snapshot are identical");
2253 }
2254 }
2255
2256 #[test]
2257 fn t019_c006_a_noop_association_change_leaves_the_figures_unchanged() {
2258 let config = registry(&[("p-aaaa", "One")]);
2265 let issues = vec![
2266 Node::new(1, "Complete").in_column("done").with(&["p-aaaa"]),
2267 Node::new(2, "Open").with(&["p-aaaa"]),
2268 ];
2269 let assigned = assigned_keys(&issues);
2270 let before = health(&config, &issues, "p-aaaa", &assigned, &set(&[2]), &set(&[])).unwrap();
2271
2272 let change = dissociate(&issues, 1, "p-bbbb").unwrap();
2273 assert_eq!(ids(&change), ["p-aaaa"], "the list is unchanged");
2274 assert!(change.event.is_none(), "no event is emitted");
2275
2276 let mut applied = issues.clone();
2277 applied[0].projects = change.project_ids.clone();
2278 let assigned_after = assigned_keys(&applied);
2279 let after = health(
2280 &config,
2281 &applied,
2282 "p-aaaa",
2283 &assigned_after,
2284 &set(&[2]),
2285 &set(&[]),
2286 )
2287 .unwrap();
2288 assert_eq!(
2289 before, after,
2290 "applying the no-op leaves the figures unchanged"
2291 );
2292 }
2293}