Skip to main content

tix/roadmap/
projection.rs

1//! The source-attributed roadmap projection (WP03, T012–T016, FR-005, C-004–C-006).
2//!
3//! A projection is a **value**, computed once per read: the ordered initiatives a
4//! normalized [`RoadmapFilter`] selects, each carrying an [`InitiativeSummary`] that
5//! *joins* the shared services rather than computing anything itself.
6//!
7//! # Join, do not blend (C-004)
8//!
9//! The mission's own risk list names the failure this module must not become: "the
10//! roadmap becomes a second metrics engine". So an [`InitiativeSummary`] carries
11//! **one contribution per referenced source entity** — the entity's kind, its
12//! stable id, its derived display label, that entity's own typed value as its
13//! owning service returned it ([`ProgressValue`]), and the shared health policy's
14//! typed risk ([`crate::health::HealthRollup`]) with its version. It invents no
15//! aggregate percentage, score or average across sources: that would be a new
16//! metric formula, which this mission is forbidden to define. Where a structural
17//! count is unavoidable it is a **count of contributions**, never a derived rate.
18//!
19//! Every figure comes from an existing entry point, called once per read:
20//!
21//! * a **project** reference joins [`crate::projects::ProjectIndex::health`];
22//! * an **epic** reference joins [`crate::epics::EpicIndex::rollup`];
23//! * every resolved reference joins [`crate::health::rollup`] for that entity's
24//!   [`crate::health::HealthScope`], which is the shared, versioned risk reading;
25//! * a reference that does not resolve is a [`RoadmapDiagnosticKind::MissingReference`]
26//!   finding, never a dropped row and never a zero.
27//!
28//! # One index, one pass (T012, T016)
29//!
30//! [`RoadmapSources`] holds the shared snapshots — the project, epic and health
31//! indices plus the consumed planning/relationship facts — built **once** per read.
32//! [`project`] builds the relationship graph once, joins every row against those
33//! indices (no per-row re-read of the store or of `tix.toml`), and sorts once
34//! through [`crate::roadmap::order`]. It stores nothing and writes nothing.
35//!
36//! # The states are not one `0` (C-005/C-006)
37//!
38//! A summary's [`SummaryState`] distinguishes `complete`, `partial`, `empty`,
39//! `missing_reference` and `unknown`, and every row carries the ordered,
40//! field-aware [`RoadmapDiagnostic`]s that explain it. A genuine zero-progress
41//! figure is a value on a contribution, never a stand-in for "nobody said".
42
43use 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// ---------------------------------------------------------------------------
60// The consumed inputs and the once-built source snapshot (T012)
61// ---------------------------------------------------------------------------
62
63/// The facts the projection **consumes** from their owners rather than deriving.
64///
65/// A projection performs no file I/O (NFR-001's exclusion) and owns no metric, so
66/// the planning overdue read, the relationship graph's blocked state, the resolved
67/// reference targets and the relationship done-column are gathered by the caller
68/// from their owning services — exactly as
69/// [`crate::commands::view::project_reports`] gathers the project inputs — and
70/// passed in. The 10,000-entity tests build them in memory.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RoadmapConsumed {
73    /// The project/milestone/cycle targets the registries resolve, from
74    /// [`crate::epics::ReferenceTargets`].
75    pub targets: ReferenceTargets,
76    /// The board's done column, as the relationship service reports it.
77    pub done_column: String,
78    /// Issue numbers with an assignee, from [`crate::projects::assigned_keys`].
79    pub assigned: HashSet<u32>,
80    /// Issue numbers past due and not done, from the planning overdue read.
81    pub overdue: HashSet<u32>,
82    /// Issue numbers with an open blocker, from the relationship graph.
83    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/// The shared snapshots a projection joins against, built **once** per read.
99///
100/// The three indices are each built in one pass over the issue set, so no row
101/// re-reads the store; the consumed facts come from their owners. The projection
102/// stores nothing and the snapshot is a value the caller drops when it is done.
103#[derive(Debug, Clone)]
104pub struct RoadmapSources<'a> {
105    /// The issue set the indices were built from, retained so the relationship
106    /// graph for epic rollups is built once here rather than per row.
107    pub issues: &'a [Issue],
108    /// The project index (`#178`).
109    pub projects: ProjectIndex,
110    /// The epic index (`#179`).
111    pub epics: EpicIndex,
112    /// The health index (`#177`).
113    pub health: HealthIndex,
114    /// The health policy in force, whose version every risk reading reports.
115    pub policy: HealthPolicy,
116    /// The facts consumed from the planning and relationship services.
117    pub consumed: RoadmapConsumed,
118}
119
120impl<'a> RoadmapSources<'a> {
121    /// Build the source snapshot in one pass over `issues`.
122    ///
123    /// The declared cycles and milestones the health index needs come from the
124    /// registries the caller already resolved ([`RoadmapConsumed::targets`]); the
125    /// index flags a member naming anything else, which the join forwards as source
126    /// diagnostics.
127    #[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// ---------------------------------------------------------------------------
152// The projection value (data-model §5, §6, §4)
153// ---------------------------------------------------------------------------
154
155/// How one reference resolved against its registry (data-model §2).
156#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
157pub enum ReferenceResolution {
158    /// The reference names a source entity the registry holds.
159    Resolved,
160    /// The reference names nothing the registry holds.
161    Missing,
162    /// The reference could name more than one source entity.
163    ///
164    /// The closed vocabulary is data-model §2's. Initiative references are keyed by
165    /// a unique stable id (projects/epics) or a case-folded-unique milestone name,
166    /// so this state is currently unreachable — it exists so a caller cannot
167    /// mistake "unresolvable" for "resolves to nothing" if a future registry admits
168    /// an ambiguous name.
169    Ambiguous,
170}
171
172impl ReferenceResolution {
173    /// The stable spelling a surface prints.
174    #[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    /// Whether the reference resolved to a source entity.
184    #[must_use]
185    pub const fn is_resolved(self) -> bool {
186        matches!(self, Self::Resolved)
187    }
188}
189
190/// The owning service's typed value for one resolved source entity.
191///
192/// One variant per owning service, carrying **that service's** returned value: a
193/// project's report from [`crate::projects::ProjectIndex::health`], an epic's rollup
194/// from [`crate::epics::EpicIndex::rollup`]. Nothing here is recomputed, and there
195/// is no cross-source variant — a blended figure would be the second metrics engine
196/// C-004 forbids.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum ProgressValue {
199    /// A project's report, as the project service returned it.
200    Project(Box<ProjectHealthReport>),
201    /// An epic's rollup, as the epic service returned it.
202    Epic(Box<EpicRollup>),
203}
204
205/// One referenced source entity's contribution to an [`InitiativeSummary`].
206///
207/// The join's atom: the source's kind, its stable id, its derived display label,
208/// its own typed value, and the shared health policy's typed risk. A reference that
209/// does not resolve is still a contribution — with [`ReferenceResolution::Missing`],
210/// no value, no risk, and the diagnostics that say why (C-005).
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct SummaryContribution {
213    /// The reference kind.
214    pub kind: AssociationKind,
215    /// The stable id (project id, epic id) or name (milestone) as recorded.
216    pub target: String,
217    /// The derived display label; the target itself for an unresolved reference.
218    pub label: String,
219    /// How the reference resolved.
220    pub resolution: ReferenceResolution,
221    /// The owning service's typed value, when it has one (projects and epics do;
222    /// a milestone's health is its risk below).
223    pub progress: Option<ProgressValue>,
224    /// The shared health policy's typed risk for the source's scope.
225    pub risk: Option<HealthRollup>,
226    /// The policy version that produced [`Self::risk`] (`health-v1`), when present.
227    pub policy_version: Option<String>,
228}
229
230impl SummaryContribution {
231    /// The source entity's stable spelling, `<kind>:<id>`, for provenance.
232    #[must_use]
233    pub fn source_entity(&self) -> String {
234        scope_of(self.kind, &self.target).as_str()
235    }
236
237    /// Whether this contribution reports anything knowable.
238    ///
239    /// A real zero-progress figure is knowable — it is a value, not an absence — so
240    /// this is `progress.is_some()` or a risk whose state is not
241    /// [`HealthState::Unknown`]. That is what keeps `0` distinct from "nobody said".
242    #[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/// The explicit state of one initiative's summary (data-model §4; C-006).
253///
254/// The five states are exhaustive and never conflated: an initiative that records
255/// nothing is not the same as one whose references are missing, and neither is a
256/// genuine zero-progress figure.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
258pub enum SummaryState {
259    /// Every recorded reference resolved and at least one reports a knowable value.
260    Complete,
261    /// Some references resolved and at least one did not.
262    Partial,
263    /// The initiative records **no** reference: nothing can contribute (C-006).
264    Empty,
265    /// At least one reference is recorded and **none** of them resolve (C-005).
266    MissingReference,
267    /// Every reference resolved, but no resolved source reports anything knowable.
268    Unknown,
269}
270
271impl SummaryState {
272    /// The stable spelling a surface prints.
273    #[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/// An initiative's derived, source-attributed summary (data-model §4).
286///
287/// A **join**: one [`SummaryContribution`] per referenced source entity, the ordered
288/// source-entity ids, the ordered policy versions, and the ordered diagnostics. No
289/// initiative-local metric formula exists here (C-004).
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct InitiativeSummary {
292    /// The stable initiative id.
293    pub initiative_id: String,
294    /// The explicit summary state.
295    pub state: SummaryState,
296    /// One contribution per referenced source entity, in reference order.
297    pub contributions: Vec<SummaryContribution>,
298    /// The resolved source entities, `<kind>:<id>`, in contribution order.
299    pub source_entities: Vec<String>,
300    /// The policy versions among the contributions, ordered and de-duplicated.
301    pub policy_versions: Vec<String>,
302    /// The ordered, field-aware findings for this summary.
303    pub diagnostics: Vec<RoadmapDiagnostic>,
304}
305
306impl InitiativeSummary {
307    /// The structural count of contributions whose source reports a knowable value.
308    ///
309    /// A **count of contributions**, never a rate, ratio or percentage: counting is
310    /// not a metric formula, and this is the only aggregate the summary exposes.
311    #[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/// One association as the row exposes it: identity, label and resolution.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct RowReference {
323    /// The reference kind.
324    pub kind: AssociationKind,
325    /// The recorded target.
326    pub target: String,
327    /// The derived display label (the target when unresolved).
328    pub label: String,
329    /// How the reference resolved.
330    pub resolution: ReferenceResolution,
331}
332
333/// One displayable initiative row (data-model §6).
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct RoadmapRow {
336    /// The stable initiative id.
337    pub id: String,
338    /// The display label.
339    pub name: String,
340    /// The optional strategic outcome.
341    pub outcome: Option<String>,
342    /// The optional owner identity.
343    pub owner: Option<String>,
344    /// The optional initiative status.
345    pub status: Option<String>,
346    /// The optional priority.
347    pub priority: Option<crate::model::Priority>,
348    /// The filterable labels.
349    pub labels: Vec<String>,
350    /// The target date; `None` is the explicit undated state (C-003).
351    pub target_date: Option<String>,
352    /// The source-attributed summary.
353    pub summary: InitiativeSummary,
354    /// The record's references, with resolution.
355    pub references: Vec<RowReference>,
356    /// The row's ordered findings.
357    pub diagnostics: Vec<RoadmapDiagnostic>,
358}
359
360/// The ordered, filterable roadmap view every surface consumes (data-model §5).
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct RoadmapProjection {
363    /// The ordered rows.
364    pub initiatives: Vec<RoadmapRow>,
365    /// The filter that selected them.
366    pub filters: RoadmapFilter,
367    /// Every row's findings, flattened and ordered.
368    pub diagnostics: Vec<RoadmapDiagnostic>,
369    /// The row count after filtering.
370    pub total: usize,
371}
372
373// ---------------------------------------------------------------------------
374// The pass (T013, T014, T015)
375// ---------------------------------------------------------------------------
376
377/// Project the filtered initiatives, joining the shared services once (T012–T016).
378///
379/// Validates the filter's syntax first, selects and orders through
380/// [`crate::roadmap::order::filter_and_order`] — the one predicate and one sort every
381/// surface shares — then joins each row against [`RoadmapSources`]. The relationship
382/// graph is built once here, so an epic rollup is an index lookup rather than a
383/// per-row rebuild. Read-only: `config` and `sources` are borrowed immutably.
384pub fn project(
385    config: &Config,
386    sources: &RoadmapSources<'_>,
387    filter: &RoadmapFilter,
388) -> Result<RoadmapProjection> {
389    let entries = filter_and_order(config, filter)?;
390
391    // Build the relationship graph once: epic rollups consume its blocked state.
392    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    // The projection-level list is exactly the rows' findings, flattened and
401    // ordered, so a surface that renders one diagnostics block cannot disagree with
402    // the per-row lists.
403    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
418/// Join one ordered entry into a displayable row.
419fn 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
520/// The ordered, de-duplicated policy versions among the contributions.
521fn 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
533/// The explicit summary state, from the resolutions and the values (T015).
534fn 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
555// ---------------------------------------------------------------------------
556// Per-kind joins (T013, T014)
557// ---------------------------------------------------------------------------
558
559/// Join one project reference through [`crate::projects::ProjectIndex::health`].
560fn 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            // The project's members' own dangling references are surfaced wherever
584            // the project's health is shown (C-005) — forwarded, never re-derived.
585            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
617/// Join one epic reference through [`crate::epics::EpicIndex::rollup`].
618fn 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
671/// Join one milestone reference through the shared health rollup.
672///
673/// A milestone has no in-memory planning progress here — its owning derived read is
674/// file-backed — so its contribution carries the health policy's risk only, which is
675/// exactly "the value its owning service returned" in this build.
676fn 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
699/// Build a resolved contribution, taking the risk's version as its provenance.
700fn 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
718/// Build a contribution for a reference that does not resolve, plus its finding.
719fn 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
750/// Build a contribution for a resolved source whose owner could not value it.
751fn 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
785/// Forward the health rollup's own scope findings as source diagnostics.
786fn 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
804/// The health scope of one association kind and target.
805fn 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        // All resolved but nothing knowable -> Unknown, distinct from Empty.
849        let unknown = vec![contribution(
850            AssociationKind::Milestone,
851            ReferenceResolution::Resolved,
852        )];
853        assert_eq!(summary_state(&unknown), SummaryState::Unknown);
854
855        // A genuinely-zero but **known** figure is Complete, not Empty and not
856        // Unknown: zero progress is a value, and the state must say so.
857        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}