Skip to main content

tix/capacity/
scope.rs

1//! The cycle/team/assignee scope indexes and the one issue snapshot every capacity
2//! calculation reads (mission `cycle-capacity-planning-01M3C85C`, WP02).
3//!
4//! # One pass, one snapshot, one reading of every estimate
5//!
6//! [`IssueSnapshot::build`] walks the issues **once**. In that single walk it indexes
7//! cycle membership, team membership, assignee membership, the shared #183 completion
8//! answer, and each issue's estimate — parsed once and converted once per unit the
9//! conversion table is resolved for. Every later question (`committed`, `completed`,
10//! `remaining`, utilization, a per-scope detail view) is then a lookup, not a scan:
11//! re-reading the store or re-parsing an estimate per scope is what NFR-001 exists to
12//! forbid, and it is also how two surfaces start disagreeing about one repository.
13//!
14//! # Cycle membership resolves through the crate's one adapter
15//!
16//! An issue's `cycles` value is a **name**, and a name may be a hand-written
17//! `[cycles.<name>]` cycle or one an issue-181 schedule generated. Both, and a
18//! generated cycle's stable `<schedule-id>:<sequence>` id, resolve through
19//! [`super::config::resolve_cycle`] — the crate's single cycle-reference resolver — so
20//! a capacity scope naming any of the three selects exactly the same issues. The
21//! snapshot keys membership on the **identity** that resolver answers with (a generated
22//! cycle's id, a legacy cycle's name) and never on the spelling, so two spellings of
23//! one cycle are one scope (data-model §2).
24//!
25//! # Team membership
26//!
27//! The only link the model offers between an issue and a team is the assignee: a team
28//! is `[teams.<label>]` and its members are assignee identities. An issue therefore
29//! belongs to a team when its `assignee`, canonicalised through
30//! [`crate::assignee::parse_assignee`] (so `ada` and `@ada` are one identity, exactly
31//! as [`super::config::validate`] folds them), is one of that team's canonical
32//! members. An issue may belong to several teams; it contributes to each once.
33//!
34//! # References that resolve to nothing are diagnostics, not guesses
35//!
36//! An issue naming a cycle nothing resolves to is reported
37//! ([`ScopeDiagnosticKind::UnknownScope`]) and contributes to **no** cycle scope: a
38//! capacity entry can only be validated against a cycle that exists, so an unresolved
39//! name could never be selected, and inventing a membership for it would be the silent
40//! guess C-003 refuses. Two spellings of one identity inside one issue are reported too
41//! ([`ScopeDiagnosticKind::AmbiguousScope`]) and deduplicated — the cycle counts once,
42//! never twice.
43
44use std::collections::btree_map::Entry;
45use std::collections::{BTreeMap, BTreeSet};
46
47use crate::assignee::parse_assignee;
48use crate::config::Config;
49use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
50use crate::error::render_diagnostics;
51use crate::model::Issue;
52use crate::wip::WipConfig;
53use crate::workflow;
54
55use super::config::{CapacityScopeKind, resolve_cycle};
56use super::estimate::EstimateValue;
57
58/// What a scope-index finding is about (data-model §5's vocabulary).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum ScopeDiagnosticKind {
61    /// An issue names a cycle that does not resolve to a record.
62    UnknownScope,
63    /// Two spellings in one issue resolve to one scope.
64    AmbiguousScope,
65}
66
67impl ScopeDiagnosticKind {
68    /// The stable spelling every surface prints.
69    #[must_use]
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::UnknownScope => "unknown_scope",
73            Self::AmbiguousScope => "ambiguous_scope",
74        }
75    }
76}
77
78/// One scope-index finding: what it is, which scope, and which issue raised it.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ScopeDiagnostic {
81    /// What is wrong.
82    pub kind: ScopeDiagnosticKind,
83    /// Which dimension the reference belongs to.
84    pub scope_kind: CapacityScopeKind,
85    /// The reference the issue wrote.
86    pub reference: String,
87    /// The issue that wrote it, when the finding came from an issue.
88    pub issue: Option<u32>,
89    /// One sentence saying what happened.
90    pub message: String,
91    /// A second sentence, when the first is not enough.
92    pub because: Option<String>,
93}
94
95impl ScopeDiagnostic {
96    /// A finding with its message.
97    #[must_use]
98    pub fn new(
99        kind: ScopeDiagnosticKind,
100        scope_kind: CapacityScopeKind,
101        reference: impl Into<String>,
102        message: impl Into<String>,
103    ) -> Self {
104        Self {
105            kind,
106            scope_kind,
107            reference: reference.into(),
108            issue: None,
109            message: message.into(),
110            because: None,
111        }
112    }
113
114    /// Attach the issue the finding came from.
115    #[must_use]
116    pub fn for_issue(mut self, number: u32) -> Self {
117        self.issue = Some(number);
118        self
119    }
120
121    /// Add the sentence that says what to do about it.
122    #[must_use]
123    pub fn because(mut self, because: impl Into<String>) -> Self {
124        self.because = Some(because.into());
125        self
126    }
127
128    /// Render through the crate's one diagnostic renderer.
129    #[must_use]
130    pub fn render(&self) -> String {
131        let mut pairs = vec![(self.kind.as_str(), self.message.as_str())];
132        if let Some(because) = self.because.as_deref() {
133            pairs.push((self.kind.as_str(), because));
134        }
135        render_diagnostics("E-tix-capacity", pairs)
136    }
137}
138
139/// The one indexed issue snapshot capacity calculations read (T011).
140///
141/// Built by [`IssueSnapshot::build`] and immutable afterwards: every lookup is a map
142/// read, so a calculation never touches a file, re-parses an estimate, or walks the
143/// issue list again — there is no seam a lookup could parse through, and no interior
144/// mutability to cache one in. The tally it exposes
145/// ([`issue_visits`](Self::issue_visits),
146/// [`estimates_parsed`](Self::estimates_parsed),
147/// [`conversions_resolved`](Self::conversions_resolved)) is the pass's own record of the
148/// parse and conversion work it performed, so a test can assert how much work the build
149/// did instead of only that it was fast. What those counters do **not** observe is stated
150/// on each of them.
151#[derive(Debug, Clone, Default, PartialEq, Eq)]
152pub struct IssueSnapshot {
153    /// Every issue number, in the order the pass saw them.
154    numbers: Vec<u32>,
155    /// A cycle identity -> its member issues, in issue order.
156    by_cycle: BTreeMap<String, Vec<u32>>,
157    /// A cycle identity -> the name it answers to, for a surface that lists scopes.
158    cycle_names: BTreeMap<String, String>,
159    /// A team label -> its member issues, in issue order.
160    by_team: BTreeMap<String, Vec<u32>>,
161    /// A canonical assignee identity -> its member issues, in issue order.
162    by_assignee: BTreeMap<String, Vec<u32>>,
163    /// An issue -> its estimate as stated, with the parse's diagnostics only.
164    stated: BTreeMap<u32, EstimateValue>,
165    /// An issue -> its estimate per target unit, conversion already applied.
166    converted: BTreeMap<u32, BTreeMap<String, EstimateValue>>,
167    /// The units every estimate was converted into, in a stable order.
168    targets: Vec<String>,
169    /// The issues #183's category service calls completed.
170    completed: BTreeSet<u32>,
171    /// The issues that belong to no cycle, team, or assignee scope.
172    unscoped: Vec<u32>,
173    /// The membership findings the pass produced, in the order it found them.
174    diagnostics: Vec<ScopeDiagnostic>,
175    /// How many issue records the pass's estimate seam walked.
176    visits: usize,
177    /// How many estimates that seam parsed.
178    parses: usize,
179    /// How many conversions that seam attempted: a readable estimate, per target unit.
180    conversions: usize,
181}
182
183impl IssueSnapshot {
184    /// Index `issues` in **one pass** against the repository's scopes and units.
185    ///
186    /// `index` is the caller's [`ScheduleIndex`], built from the cycle records
187    /// (`crate::capacity::schedule_index`, or [`crate::cycle_schedule::registry::load_index`]
188    /// — the same index either way). The units an estimate is converted into are the
189    /// board's declared units plus every unit a configured capacity entry names, read
190    /// from the config here: the conversion table is resolved **once**, at build time,
191    /// so no calculation repeats it (R10).
192    ///
193    /// Conversions use the **default board**'s registry (`config.default_config()`,
194    /// the board the CLI and other single-config surfaces run under, and the same board
195    /// [`crate::workflow::resolution`] is built for here). A capacity scope names a
196    /// cycle, a team, or an assignee and never a board, so there is no other board the
197    /// arithmetic could run under; a unit *that* board does not declare is a
198    /// `missing_conversion`/`unknown_unit` diagnostic per issue, never a guess.
199    ///
200    /// Completion is [`crate::workflow::is_completed`]'s answer (C-002): the snapshot
201    /// records what the one status-category service decides and never re-derives it.
202    #[must_use]
203    #[allow(clippy::too_many_lines)]
204    pub fn build(config: &Config, index: &ScheduleIndex, issues: &[Issue]) -> Self {
205        let board = config.default_config();
206        let resolution = workflow::resolution(config, board, "default");
207
208        let mut targets: BTreeSet<String> = board.wip.units.keys().cloned().collect();
209        for (_, entry) in config.capacity.iter() {
210            if let Some(unit) = entry
211                .unit
212                .as_deref()
213                .map(str::trim)
214                .filter(|unit| !unit.is_empty())
215            {
216                targets.insert(unit.to_string());
217            }
218        }
219        let targets: Vec<String> = targets.into_iter().collect();
220
221        // Which teams a canonical assignee identity belongs to. Read from the config
222        // once, so the issue pass stays a single pass.
223        let mut teams_of: BTreeMap<String, Vec<String>> = BTreeMap::new();
224        for (label, team) in &config.teams {
225            for member in &team.members {
226                let Some(handle) = parse_assignee(member) else {
227                    continue;
228                };
229                let teams = teams_of.entry(handle).or_default();
230                if !teams.contains(label) {
231                    teams.push(label.clone());
232                }
233            }
234        }
235
236        let mut snapshot = Self {
237            targets: targets.clone(),
238            ..Self::default()
239        };
240        snapshot.numbers.reserve(issues.len());
241        for issue in issues {
242            snapshot.numbers.push(issue.number);
243
244            let (stated, per_unit) = snapshot.read_issue(issue, &board.wip, &targets);
245            snapshot.converted.insert(issue.number, per_unit);
246            snapshot.stated.insert(issue.number, stated);
247
248            if workflow::is_completed(issue, &resolution) {
249                snapshot.completed.insert(issue.number);
250            }
251
252            // The cycle memberships, keyed on the identity `resolve_cycle` answers
253            // with rather than on the spelling the issue used.
254            let mut spellings: BTreeMap<String, String> = BTreeMap::new();
255            let mut scoped = false;
256            for reference in issue.cycles.iter().flatten() {
257                let cycle = resolve_cycle(index, reference);
258                let member = match &cycle {
259                    CycleReference::Generated { name, id, .. } => {
260                        Some((format!("cycle-id:{id}"), name.clone()))
261                    }
262                    CycleReference::Legacy { name } => {
263                        Some((format!("cycle-name:{name}"), name.clone()))
264                    }
265                    CycleReference::Unknown { name } => {
266                        snapshot.diagnostics.push(
267                            ScopeDiagnostic::new(
268                                ScopeDiagnosticKind::UnknownScope,
269                                CapacityScopeKind::Cycle,
270                                name.clone(),
271                                format!(
272                                    "issue #{} `cycles` names `{name}`, which does not resolve \
273                                     to a cycle record",
274                                    issue.number
275                                ),
276                            )
277                            .for_issue(issue.number)
278                            .because(
279                                "the issue counts in no cycle until that cycle exists; an \
280                                 unresolvable name is reported, never guessed (C-003)",
281                            ),
282                        );
283                        None
284                    }
285                };
286                let Some((key, name)) = member else {
287                    continue;
288                };
289                snapshot.cycle_names.entry(key.clone()).or_insert(name);
290                scoped = true;
291                match spellings.entry(key.clone()) {
292                    Entry::Vacant(slot) => {
293                        slot.insert(reference.clone());
294                        snapshot.by_cycle.entry(key).or_default().push(issue.number);
295                    }
296                    // A second spelling of one identity is one contribution, and the
297                    // ambiguity is worth saying out loud.
298                    Entry::Occupied(slot) => {
299                        if slot.get() != reference {
300                            snapshot.diagnostics.push(
301                                ScopeDiagnostic::new(
302                                    ScopeDiagnosticKind::AmbiguousScope,
303                                    CapacityScopeKind::Cycle,
304                                    reference.clone(),
305                                    format!(
306                                        "issue #{} `cycles` names `{reference}` and `{}`, \
307                                         which resolve to one cycle",
308                                        issue.number,
309                                        slot.get()
310                                    ),
311                                )
312                                .for_issue(issue.number)
313                                .because("the cycle counts once, not twice"),
314                            );
315                        }
316                    }
317                }
318            }
319
320            if let Some(handle) = issue.assignee.as_deref().and_then(parse_assignee) {
321                scoped = true;
322                snapshot
323                    .by_assignee
324                    .entry(handle.clone())
325                    .or_default()
326                    .push(issue.number);
327                for label in teams_of.get(&handle).into_iter().flatten() {
328                    snapshot
329                        .by_team
330                        .entry(label.clone())
331                        .or_default()
332                        .push(issue.number);
333                }
334            }
335
336            if !scoped {
337                snapshot.unscoped.push(issue.number);
338            }
339        }
340
341        for members in snapshot
342            .by_cycle
343            .values_mut()
344            .chain(snapshot.by_team.values_mut())
345            .chain(snapshot.by_assignee.values_mut())
346        {
347            members.sort_unstable();
348            members.dedup();
349        }
350        snapshot.unscoped.sort_unstable();
351        snapshot.unscoped.dedup();
352        snapshot
353    }
354
355    /// One issue record's worth of the pass: the estimate as the issue stated it, and
356    /// its reading in every target unit.
357    ///
358    /// This is the **one** place the snapshot parses an estimate or asks the registry
359    /// for a conversion, and the one place the pass's tally
360    /// ([`issue_visits`](Self::issue_visits),
361    /// [`estimates_parsed`](Self::estimates_parsed),
362    /// [`conversions_resolved`](Self::conversions_resolved)) grows. Keeping the work and
363    /// its record in the same method is what lets those counters say something: work
364    /// duplicated through this seam is counted twice and breaks the tests that anchor the
365    /// tally to the slice. Work that bypasses the seam is not counted — see the
366    /// accessors for exactly what the tally does and does not prove.
367    fn read_issue(
368        &mut self,
369        issue: &Issue,
370        registry: &WipConfig,
371        targets: &[String],
372    ) -> (EstimateValue, BTreeMap<String, EstimateValue>) {
373        self.visits += 1;
374        let stated = match issue.estimate.as_deref() {
375            // A blank estimate states nothing, so it is unestimated rather than
376            // invalid — the same rule the WIP aggregate follows.
377            Some(raw) if !raw.trim().is_empty() => {
378                self.parses += 1;
379                EstimateValue::parse(issue.number, raw)
380            }
381            _ => EstimateValue::unestimated(),
382        };
383        let mut per_unit: BTreeMap<String, EstimateValue> = BTreeMap::new();
384        for unit in targets {
385            per_unit.insert(
386                unit.clone(),
387                stated.converted_to(issue.number, registry, unit),
388            );
389        }
390        if stated.is_valid() {
391            self.conversions += targets.len();
392        }
393        (stated, per_unit)
394    }
395
396    /// Every issue number, in the order the pass saw them.
397    #[must_use]
398    pub fn issues(&self) -> &[u32] {
399        &self.numbers
400    }
401
402    /// How many issues the snapshot indexed.
403    #[must_use]
404    pub fn len(&self) -> usize {
405        self.numbers.len()
406    }
407
408    /// Whether the snapshot indexed no issues.
409    #[must_use]
410    pub fn is_empty(&self) -> bool {
411        self.numbers.is_empty()
412    }
413
414    /// The units every estimate was converted into, in a stable order (T007).
415    #[must_use]
416    pub fn targets(&self) -> &[String] {
417        &self.targets
418    }
419
420    /// An issue's estimate as stated: the raw text, the parsed unit and value, and the
421    /// parse diagnostics. No conversion has been applied.
422    #[must_use]
423    pub fn estimate(&self, number: u32) -> Option<&EstimateValue> {
424        self.stated.get(&number)
425    }
426
427    /// An issue's estimate converted into `unit`, conversion included.
428    ///
429    /// `None` only when the issue is not in the snapshot or `unit` was not in the
430    /// conversion table — the units [`targets`](Self::targets) lists. Every issue in the
431    /// snapshot otherwise answers, including an unestimated one (which stays
432    /// unestimated) and an invalid one (whose diagnostics say why).
433    #[must_use]
434    pub fn estimate_in(&self, number: u32, unit: &str) -> Option<&EstimateValue> {
435        self.converted.get(&number)?.get(unit)
436    }
437
438    /// Whether #183's category service calls this issue completed.
439    #[must_use]
440    pub fn is_completed(&self, number: u32) -> bool {
441        self.completed.contains(&number)
442    }
443
444    /// The issues #183's category service calls completed, in issue order.
445    #[must_use]
446    pub fn completed(&self) -> &BTreeSet<u32> {
447        &self.completed
448    }
449
450    /// The issues whose `cycles` resolve to `reference`'s identity.
451    ///
452    /// `reference` may be a hand-written cycle's name, a generated cycle's name, or a
453    /// generated cycle's stable id: all three go through [`resolve_cycle`], so all
454    /// three select the same set (T010). An unknown reference selects nobody.
455    #[must_use]
456    pub fn cycle_members(&self, index: &ScheduleIndex, reference: &str) -> &[u32] {
457        let key = match resolve_cycle(index, reference) {
458            CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
459            CycleReference::Legacy { name } => format!("cycle-name:{name}"),
460            CycleReference::Unknown { .. } => return &[],
461        };
462        self.by_cycle.get(&key).map_or(&[], Vec::as_slice)
463    }
464
465    /// The issues whose assignee is a member of team `label`.
466    #[must_use]
467    pub fn team_members(&self, label: &str) -> &[u32] {
468        self.by_team.get(label).map_or(&[], Vec::as_slice)
469    }
470
471    /// The issues assigned to `reference`, by the model's assignee identity.
472    ///
473    /// The reference folds the way [`crate::assignee::parse_assignee`] folds it, so
474    /// `ada` and `@ada` select the same issues and `Ada Lovelace <ada@example.com>` is
475    /// its own identity — the same folding [`super::config::validate`] applies.
476    #[must_use]
477    pub fn assignee_members(&self, reference: &str) -> &[u32] {
478        match parse_assignee(reference) {
479            Some(handle) => self.by_assignee.get(&handle).map_or(&[], Vec::as_slice),
480            None => &[],
481        }
482    }
483
484    /// The issues in one scope, whichever dimension it is.
485    ///
486    /// The one call a calculation needs: it dispatches on the entry's scope kind, so
487    /// no surface re-implements the per-kind lookup.
488    #[must_use]
489    pub fn members(
490        &self,
491        kind: CapacityScopeKind,
492        reference: &str,
493        index: &ScheduleIndex,
494    ) -> &[u32] {
495        match kind {
496            CapacityScopeKind::Cycle => self.cycle_members(index, reference),
497            CapacityScopeKind::Team => self.team_members(reference),
498            CapacityScopeKind::Assignee => self.assignee_members(reference),
499        }
500    }
501
502    /// The cycles with at least one member, by the name they answer to.
503    #[must_use]
504    pub fn cycle_scopes(&self) -> Vec<&str> {
505        self.by_cycle
506            .keys()
507            .map(|key| {
508                self.cycle_names
509                    .get(key)
510                    .map_or(key.as_str(), String::as_str)
511            })
512            .collect()
513    }
514
515    /// The teams with at least one member.
516    #[must_use]
517    pub fn team_scopes(&self) -> Vec<&str> {
518        self.by_team.keys().map(String::as_str).collect()
519    }
520
521    /// The assignee identities with at least one member.
522    #[must_use]
523    pub fn assignee_scopes(&self) -> Vec<&str> {
524        self.by_assignee.keys().map(String::as_str).collect()
525    }
526
527    /// The issues that belong to no cycle, team, or assignee scope, in issue order.
528    ///
529    /// An issue naming only a cycle that does not resolve is here too: it is in no
530    /// scope a capacity entry could select.
531    #[must_use]
532    pub fn unscoped(&self) -> &[u32] {
533        &self.unscoped
534    }
535
536    /// The membership findings the pass produced, in the order it found them.
537    #[must_use]
538    pub fn diagnostics(&self) -> &[ScopeDiagnostic] {
539        &self.diagnostics
540    }
541
542    /// How many issue records the pass's estimate seam walked — the cursor's record.
543    ///
544    /// One per issue by construction: the pass walks the slice once and this counts the
545    /// walk. It is a **discipline about this pass**, not evidence about code outside it —
546    /// a second scan elsewhere in the crate would not appear here, and neither would a
547    /// re-parse that bypassed the seam. The counters that can catch duplicated work are
548    /// [`estimates_parsed`](Self::estimates_parsed) and
549    /// [`conversions_resolved`](Self::conversions_resolved), which are incremented by the
550    /// work itself.
551    #[must_use]
552    pub fn issue_visits(&self) -> usize {
553        self.visits
554    }
555
556    /// How many estimates the pass parsed: one per issue that stated one.
557    ///
558    /// Counted where the parse happens, so a second read of the estimates through the
559    /// same seam counts twice and is visible; the counters measure the work performed,
560    /// not the absence of other code. A lookup adds nothing, because a lookup does not
561    /// parse (NFR-001).
562    #[must_use]
563    pub fn estimates_parsed(&self) -> usize {
564        self.parses
565    }
566
567    /// How many explicit conversions the pass attempted: one per issue with a readable
568    /// estimate, per unit in [`targets`](Self::targets).
569    ///
570    /// This counts the **attempts** — the conversion seam was consulted exactly that
571    /// many times, in this pass and nowhere else. Whether a particular attempt produced
572    /// a number is read from the value itself
573    /// ([`is_converted`](EstimateValue::is_converted)), because a refusal is a
574    /// diagnostic on the value, not the loss of one.
575    #[must_use]
576    pub fn conversions_resolved(&self) -> usize {
577        self.conversions
578    }
579}