1use std::collections::HashSet;
44
45use crate::config::{Config, EpicId, InitiativeId, ProjectId};
46use crate::epics::{EpicIndex, EpicRollup, ReferenceTargets};
47use crate::error::Result;
48use crate::health::{
49 HealthIndex, HealthPolicy, HealthRollup, HealthScope, HealthState, rollup as health_rollup,
50};
51use crate::model::Issue;
52use crate::projects::{ProjectHealthReport, ProjectIndex};
53use crate::relationships::{IssueView, RelationshipGraph};
54use crate::roadmap::diagnostics::{self, RoadmapDiagnostic, RoadmapDiagnosticKind};
55use crate::roadmap::filter::RoadmapFilter;
56use crate::roadmap::order::{RoadmapEntry, filter_and_order};
57use crate::roadmap::registry::{self, AssociationKind};
58
59#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RoadmapConsumed {
73 pub targets: ReferenceTargets,
76 pub done_column: String,
78 pub assigned: HashSet<u32>,
80 pub overdue: HashSet<u32>,
82 pub blocked: HashSet<u32>,
84}
85
86impl Default for RoadmapConsumed {
87 fn default() -> Self {
88 Self {
89 targets: ReferenceTargets::default(),
90 done_column: "done".to_string(),
91 assigned: HashSet::new(),
92 overdue: HashSet::new(),
93 blocked: HashSet::new(),
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
104pub struct RoadmapSources<'a> {
105 pub issues: &'a [Issue],
108 pub projects: ProjectIndex,
110 pub epics: EpicIndex,
112 pub health: HealthIndex,
114 pub policy: HealthPolicy,
116 pub consumed: RoadmapConsumed,
118}
119
120impl<'a> RoadmapSources<'a> {
121 #[must_use]
128 pub fn new(
129 config: &Config,
130 issues: &'a [Issue],
131 policy: &HealthPolicy,
132 consumed: RoadmapConsumed,
133 ) -> Self {
134 let projects = ProjectIndex::build(config, issues);
135 let epics = EpicIndex::build(config, issues);
136 let cycles: Vec<String> = config.cycles.keys().cloned().collect();
137 let mut milestones: Vec<String> = consumed.targets.milestones.iter().cloned().collect();
138 milestones.sort();
139 let health = HealthIndex::build(issues, &cycles, &milestones, policy);
140 Self {
141 issues,
142 projects,
143 epics,
144 health,
145 policy: policy.clone(),
146 consumed,
147 }
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
157pub enum ReferenceResolution {
158 Resolved,
160 Missing,
162 Ambiguous,
170}
171
172impl ReferenceResolution {
173 #[must_use]
175 pub const fn as_str(self) -> &'static str {
176 match self {
177 Self::Resolved => "resolved",
178 Self::Missing => "missing",
179 Self::Ambiguous => "ambiguous",
180 }
181 }
182
183 #[must_use]
185 pub const fn is_resolved(self) -> bool {
186 matches!(self, Self::Resolved)
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum ProgressValue {
199 Project(Box<ProjectHealthReport>),
201 Epic(Box<EpicRollup>),
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct SummaryContribution {
213 pub kind: AssociationKind,
215 pub target: String,
217 pub label: String,
219 pub resolution: ReferenceResolution,
221 pub progress: Option<ProgressValue>,
224 pub risk: Option<HealthRollup>,
226 pub policy_version: Option<String>,
228}
229
230impl SummaryContribution {
231 #[must_use]
233 pub fn source_entity(&self) -> String {
234 scope_of(self.kind, &self.target).as_str()
235 }
236
237 #[must_use]
243 pub fn is_knowable(&self) -> bool {
244 self.progress.is_some()
245 || self
246 .risk
247 .as_ref()
248 .is_some_and(|risk| risk.state != HealthState::Unknown)
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
258pub enum SummaryState {
259 Complete,
261 Partial,
263 Empty,
265 MissingReference,
267 Unknown,
269}
270
271impl SummaryState {
272 #[must_use]
274 pub const fn as_str(self) -> &'static str {
275 match self {
276 Self::Complete => "complete",
277 Self::Partial => "partial",
278 Self::Empty => "empty",
279 Self::MissingReference => "missing_reference",
280 Self::Unknown => "unknown",
281 }
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct InitiativeSummary {
292 pub initiative_id: String,
294 pub state: SummaryState,
296 pub contributions: Vec<SummaryContribution>,
298 pub source_entities: Vec<String>,
300 pub policy_versions: Vec<String>,
302 pub diagnostics: Vec<RoadmapDiagnostic>,
304}
305
306impl InitiativeSummary {
307 #[must_use]
312 pub fn knowable_contributions(&self) -> usize {
313 self.contributions
314 .iter()
315 .filter(|contribution| contribution.is_knowable())
316 .count()
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct RowReference {
323 pub kind: AssociationKind,
325 pub target: String,
327 pub label: String,
329 pub resolution: ReferenceResolution,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct RoadmapRow {
336 pub id: String,
338 pub name: String,
340 pub outcome: Option<String>,
342 pub owner: Option<String>,
344 pub status: Option<String>,
346 pub priority: Option<crate::model::Priority>,
348 pub labels: Vec<String>,
350 pub target_date: Option<String>,
352 pub summary: InitiativeSummary,
354 pub references: Vec<RowReference>,
356 pub diagnostics: Vec<RoadmapDiagnostic>,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct RoadmapProjection {
363 pub initiatives: Vec<RoadmapRow>,
365 pub filters: RoadmapFilter,
367 pub diagnostics: Vec<RoadmapDiagnostic>,
369 pub total: usize,
371}
372
373pub fn project(
385 config: &Config,
386 sources: &RoadmapSources<'_>,
387 filter: &RoadmapFilter,
388) -> Result<RoadmapProjection> {
389 let entries = filter_and_order(config, filter)?;
390
391 let views: Vec<IssueView<'_>> = sources.issues.iter().map(Issue::issue_view).collect();
393 let graph = RelationshipGraph::new(&views, &sources.consumed.done_column);
394
395 let mut rows: Vec<RoadmapRow> = Vec::with_capacity(entries.len());
396 for entry in &entries {
397 rows.push(join_row(config, sources, &graph, entry));
398 }
399
400 let mut diagnostics: Vec<RoadmapDiagnostic> = rows
404 .iter()
405 .flat_map(|row| row.diagnostics.iter().cloned())
406 .collect();
407 diagnostics::order(&mut diagnostics);
408
409 let total = rows.len();
410 Ok(RoadmapProjection {
411 initiatives: rows,
412 filters: filter.clone(),
413 diagnostics,
414 total,
415 })
416}
417
418fn join_row(
420 config: &Config,
421 sources: &RoadmapSources<'_>,
422 graph: &RelationshipGraph<'_>,
423 entry: &RoadmapEntry<'_>,
424) -> RoadmapRow {
425 let record = entry.initiative;
426 let mut contributions: Vec<SummaryContribution> = Vec::new();
427 let mut references: Vec<RowReference> = Vec::new();
428 let mut findings: Vec<RoadmapDiagnostic> = Vec::new();
429
430 let associations = InitiativeId::new(entry.id)
431 .map(|id| registry::associations(config, &id))
432 .unwrap_or_default();
433 for association in &associations {
434 let (contribution, mut diagnostics) = match association.kind {
435 AssociationKind::Project => {
436 join_project(config, sources, entry.id, &association.target)
437 }
438 AssociationKind::Epic => {
439 join_epic(config, sources, graph, entry.id, &association.target)
440 }
441 AssociationKind::Milestone => join_milestone(sources, entry.id, &association.target),
442 };
443 references.push(RowReference {
444 kind: contribution.kind,
445 target: contribution.target.clone(),
446 label: contribution.label.clone(),
447 resolution: contribution.resolution,
448 });
449 contributions.push(contribution);
450 findings.append(&mut diagnostics);
451 }
452
453 let state = summary_state(&contributions);
454 match state {
455 SummaryState::Empty => findings.push(
456 RoadmapDiagnostic::new(
457 RoadmapDiagnosticKind::EmptyScope,
458 format!(
459 "initiative `{}` records no project, epic or milestone reference",
460 entry.id
461 ),
462 )
463 .for_initiative(entry.id),
464 ),
465 SummaryState::Unknown => findings.push(
466 RoadmapDiagnostic::new(
467 RoadmapDiagnosticKind::UnknownValue,
468 format!(
469 "initiative `{}` resolves every reference, but no source reports a value",
470 entry.id
471 ),
472 )
473 .for_initiative(entry.id),
474 ),
475 _ => {}
476 }
477 if entry.target_date.is_undated() {
478 findings.push(
479 RoadmapDiagnostic::new(
480 RoadmapDiagnosticKind::Undated,
481 format!("initiative `{}` has no target date", entry.id),
482 )
483 .for_initiative(entry.id)
484 .at_field("target_date"),
485 );
486 }
487 diagnostics::order(&mut findings);
488
489 let source_entities: Vec<String> = contributions
490 .iter()
491 .filter(|contribution| contribution.resolution.is_resolved())
492 .map(SummaryContribution::source_entity)
493 .collect();
494 let policy_versions = ordered_policy_versions(&contributions);
495
496 let summary = InitiativeSummary {
497 initiative_id: entry.id.to_string(),
498 state,
499 contributions,
500 source_entities,
501 policy_versions,
502 diagnostics: findings.clone(),
503 };
504
505 RoadmapRow {
506 id: entry.id.to_string(),
507 name: record.name.clone(),
508 outcome: record.outcome.clone(),
509 owner: record.owner.clone(),
510 status: record.status.clone(),
511 priority: record.priority.clone(),
512 labels: record.labels.clone(),
513 target_date: record.target_date.clone(),
514 summary,
515 references,
516 diagnostics: findings,
517 }
518}
519
520fn ordered_policy_versions(contributions: &[SummaryContribution]) -> Vec<String> {
522 let mut versions: Vec<String> = Vec::new();
523 for contribution in contributions {
524 if let Some(version) = &contribution.policy_version {
525 if !versions.iter().any(|seen| seen == version) {
526 versions.push(version.clone());
527 }
528 }
529 }
530 versions
531}
532
533fn summary_state(contributions: &[SummaryContribution]) -> SummaryState {
535 if contributions.is_empty() {
536 return SummaryState::Empty;
537 }
538 let resolved = contributions
539 .iter()
540 .filter(|contribution| contribution.resolution.is_resolved())
541 .count();
542 if resolved == 0 {
543 return SummaryState::MissingReference;
544 }
545 if resolved < contributions.len() {
546 return SummaryState::Partial;
547 }
548 if contributions.iter().any(SummaryContribution::is_knowable) {
549 SummaryState::Complete
550 } else {
551 SummaryState::Unknown
552 }
553}
554
555fn join_project(
561 config: &Config,
562 sources: &RoadmapSources<'_>,
563 initiative: &str,
564 target: &str,
565) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
566 let resolved = ProjectId::new(target)
567 .ok()
568 .and_then(|id| sources.projects.project(&id));
569 let Some(project) = resolved else {
570 return missing_contribution(AssociationKind::Project, target, initiative);
571 };
572 let scope = HealthScope::Project(project.id.to_string());
573 match sources.projects.health(
574 config,
575 project.id.as_str(),
576 &sources.consumed.assigned,
577 &sources.consumed.overdue,
578 &sources.consumed.blocked,
579 ) {
580 Ok(report) => {
581 let risk = health_rollup(&sources.health, &scope, &sources.policy);
582 let mut diagnostics = source_diagnostics(&scope, &risk);
583 for missing in &report.health.missing_references {
586 diagnostics.push(
587 RoadmapDiagnostic::new(
588 RoadmapDiagnosticKind::SourceDiagnostic,
589 &missing.reason,
590 )
591 .about_source(scope.as_str())
592 .at_field(AssociationKind::Project.field())
593 .referencing(&missing.project_id),
594 );
595 }
596 (
597 resolved_contribution(
598 AssociationKind::Project,
599 target,
600 project.name.clone(),
601 Some(ProgressValue::Project(Box::new(report))),
602 Some(&risk),
603 ),
604 diagnostics,
605 )
606 }
607 Err(err) => unresolvable_contribution(
608 AssociationKind::Project,
609 target,
610 project.name.clone(),
611 &err.to_string(),
612 initiative,
613 ),
614 }
615}
616
617fn join_epic(
619 config: &Config,
620 sources: &RoadmapSources<'_>,
621 graph: &RelationshipGraph<'_>,
622 initiative: &str,
623 target: &str,
624) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
625 let resolved = EpicId::new(target)
626 .ok()
627 .and_then(|id| sources.epics.epic(&id));
628 let Some(epic) = resolved else {
629 return missing_contribution(AssociationKind::Epic, target, initiative);
630 };
631 let scope = HealthScope::Epic(epic.id.to_string());
632 match sources
633 .epics
634 .rollup(config, target, &sources.consumed.targets, graph)
635 {
636 Ok(rollup) => {
637 let risk = health_rollup(&sources.health, &scope, &sources.policy);
638 let mut diagnostics = source_diagnostics(&scope, &risk);
639 for missing in &rollup.missing_references {
640 diagnostics.push(
641 RoadmapDiagnostic::new(
642 RoadmapDiagnosticKind::SourceDiagnostic,
643 &missing.reason,
644 )
645 .about_source(scope.as_str())
646 .at_field(AssociationKind::Epic.field())
647 .referencing(&missing.target),
648 );
649 }
650 (
651 resolved_contribution(
652 AssociationKind::Epic,
653 target,
654 epic.name.clone(),
655 Some(ProgressValue::Epic(Box::new(rollup))),
656 Some(&risk),
657 ),
658 diagnostics,
659 )
660 }
661 Err(err) => unresolvable_contribution(
662 AssociationKind::Epic,
663 target,
664 epic.name.clone(),
665 &err.to_string(),
666 initiative,
667 ),
668 }
669}
670
671fn join_milestone(
677 sources: &RoadmapSources<'_>,
678 initiative: &str,
679 target: &str,
680) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
681 if !sources.consumed.targets.milestone_known(target) {
682 return missing_contribution(AssociationKind::Milestone, target, initiative);
683 }
684 let scope = HealthScope::Milestone(target.to_string());
685 let risk = health_rollup(&sources.health, &scope, &sources.policy);
686 let diagnostics = source_diagnostics(&scope, &risk);
687 (
688 resolved_contribution(
689 AssociationKind::Milestone,
690 target,
691 target.to_string(),
692 None,
693 Some(&risk),
694 ),
695 diagnostics,
696 )
697}
698
699fn resolved_contribution(
701 kind: AssociationKind,
702 target: &str,
703 label: String,
704 progress: Option<ProgressValue>,
705 risk: Option<&HealthRollup>,
706) -> SummaryContribution {
707 SummaryContribution {
708 kind,
709 target: target.to_string(),
710 label,
711 resolution: ReferenceResolution::Resolved,
712 progress,
713 risk: risk.cloned(),
714 policy_version: risk.map(|risk| risk.policy_version.clone()),
715 }
716}
717
718fn missing_contribution(
720 kind: AssociationKind,
721 target: &str,
722 initiative: &str,
723) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
724 let contribution = SummaryContribution {
725 kind,
726 target: target.to_string(),
727 label: target.to_string(),
728 resolution: ReferenceResolution::Missing,
729 progress: None,
730 risk: None,
731 policy_version: None,
732 };
733 let diagnostic = RoadmapDiagnostic::new(
734 RoadmapDiagnosticKind::MissingReference,
735 format!(
736 "`{}`: reference `{target}` does not resolve in the registry",
737 kind.field()
738 ),
739 )
740 .at_field(kind.field())
741 .referencing(target);
742 let diagnostic = if initiative.is_empty() {
743 diagnostic
744 } else {
745 diagnostic.for_initiative(initiative)
746 };
747 (contribution, vec![diagnostic])
748}
749
750fn unresolvable_contribution(
752 kind: AssociationKind,
753 target: &str,
754 label: String,
755 reason: &str,
756 initiative: &str,
757) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
758 let contribution = SummaryContribution {
759 kind,
760 target: target.to_string(),
761 label,
762 resolution: ReferenceResolution::Resolved,
763 progress: None,
764 risk: None,
765 policy_version: None,
766 };
767 let diagnostic = RoadmapDiagnostic::new(
768 RoadmapDiagnosticKind::UnresolvableRollup,
769 format!(
770 "`{}`: `{target}` resolved but its value could not be derived: {reason}",
771 kind.field()
772 ),
773 )
774 .at_field(kind.field())
775 .referencing(target)
776 .about_source(scope_of(kind, target).as_str());
777 let diagnostic = if initiative.is_empty() {
778 diagnostic
779 } else {
780 diagnostic.for_initiative(initiative)
781 };
782 (contribution, vec![diagnostic])
783}
784
785fn source_diagnostics(scope: &HealthScope, risk: &HealthRollup) -> Vec<RoadmapDiagnostic> {
787 risk.diagnostics
788 .iter()
789 .map(|finding| {
790 let mut diagnostic =
791 RoadmapDiagnostic::new(RoadmapDiagnosticKind::SourceDiagnostic, &finding.message)
792 .about_source(scope.as_str());
793 if let Some(reference) = &finding.reference {
794 diagnostic = diagnostic.referencing(reference);
795 }
796 match &finding.field {
797 Some(field) => diagnostic.at_field(field),
798 None => diagnostic,
799 }
800 })
801 .collect()
802}
803
804fn scope_of(kind: AssociationKind, target: &str) -> HealthScope {
806 match kind {
807 AssociationKind::Project => HealthScope::Project(target.to_string()),
808 AssociationKind::Epic => HealthScope::Epic(target.to_string()),
809 AssociationKind::Milestone => HealthScope::Milestone(target.to_string()),
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 #[allow(clippy::wildcard_imports)]
816 use super::*;
817 use crate::planning::Progress;
818 use crate::projects::{ProjectHealth, ProjectProgress};
819
820 fn contribution(kind: AssociationKind, resolution: ReferenceResolution) -> SummaryContribution {
821 SummaryContribution {
822 kind,
823 target: "x".to_string(),
824 label: "X".to_string(),
825 resolution,
826 progress: None,
827 risk: None,
828 policy_version: None,
829 }
830 }
831
832 #[test]
833 fn summary_state_is_exhaustive_and_never_conflates_zero_with_absence() {
834 assert_eq!(summary_state(&[]), SummaryState::Empty);
835
836 let missing = vec![contribution(
837 AssociationKind::Project,
838 ReferenceResolution::Missing,
839 )];
840 assert_eq!(summary_state(&missing), SummaryState::MissingReference);
841
842 let partial = vec![
843 contribution(AssociationKind::Project, ReferenceResolution::Resolved),
844 contribution(AssociationKind::Epic, ReferenceResolution::Missing),
845 ];
846 assert_eq!(summary_state(&partial), SummaryState::Partial);
847
848 let unknown = vec![contribution(
850 AssociationKind::Milestone,
851 ReferenceResolution::Resolved,
852 )];
853 assert_eq!(summary_state(&unknown), SummaryState::Unknown);
854
855 let zero = SummaryContribution {
858 kind: AssociationKind::Project,
859 target: "p-zero".to_string(),
860 label: "Zero".to_string(),
861 resolution: ReferenceResolution::Resolved,
862 progress: Some(ProgressValue::Project(Box::new(ProjectHealthReport {
863 project_id: ProjectId::new("p-zero").expect("a valid id"),
864 name: "Zero".to_string(),
865 progress: ProjectProgress {
866 done: 0,
867 total: 0,
868 progress: Progress { done: 0, total: 0 },
869 },
870 health: ProjectHealth {
871 overdue: 0,
872 blocked: 0,
873 unassigned: 0,
874 missing_references: Vec::new(),
875 },
876 }))),
877 risk: None,
878 policy_version: None,
879 };
880 assert!(zero.is_knowable());
881 assert_eq!(summary_state(&[zero]), SummaryState::Complete);
882 }
883
884 #[test]
885 fn ordered_policy_versions_dedups_preserving_first_seen_order() {
886 let mut a = contribution(AssociationKind::Milestone, ReferenceResolution::Resolved);
887 a.policy_version = Some("health-v1".to_string());
888 let mut b = contribution(AssociationKind::Milestone, ReferenceResolution::Resolved);
889 b.policy_version = Some("health-v1".to_string());
890 let versions = ordered_policy_versions(&[a, b]);
891 assert_eq!(versions, vec!["health-v1".to_string()]);
892 }
893
894 #[test]
895 fn scope_spelling_is_stable_per_kind() {
896 assert_eq!(
897 scope_of(AssociationKind::Project, "p-1").as_str(),
898 "project:p-1"
899 );
900 assert_eq!(scope_of(AssociationKind::Epic, "e-1").as_str(), "epic:e-1");
901 assert_eq!(
902 scope_of(AssociationKind::Milestone, "m1").as_str(),
903 "milestone:m1"
904 );
905 }
906}