Skip to main content

tix/time_tracking/
aggregate.rs

1//! One-pass integer-minute totals and the counts beside them (mission
2//! `per-issue-time-tracking-01M3C9A4`, WP03 T014–T018).
3//!
4//! # One snapshot, derived once
5//!
6//! [`TimeSnapshot::of`] indexes the issues a caller already holds **once**: every
7//! current entry with the issue it belongs to, in the data-model §6 order (issue,
8//! then dated entries oldest first, undated last, ties by stable id), plus the time
9//! History each issue retains. Every scope — a whole filter, a per-issue breakdown,
10//! a per-identity breakdown, a per-date breakdown — is then evaluated against that
11//! in-memory index. Nothing here opens a file, reads a clock or re-parses
12//! frontmatter, and [`TimeSnapshot::totals`] evaluates a whole set of requested
13//! scopes with no second scan, so `columns × entries` cannot creep back in and two
14//! surfaces cannot count the same entries differently (T014, NFR-001, C-005).
15//!
16//! # Unknown is not zero, and nothing is not zero
17//!
18//! [`TimeTotal::total_minutes`] is an `Option`: `Some(0)` means a zero was
19//! **counted**, and `None` means nothing was counted at all. The two are kept apart
20//! because they answer different questions — "0m" is a measurement, and no
21//! measurement at all is not one — and [`TimeTotal::state`] names which case a
22//! reader is in: a scope that selected no entry is [`TimeTotalState::Nothing`], one
23//! whose selected entries came back without a number (every one malformed, or the
24//! period could not place the valid undated ones) is [`TimeTotalState::Unknown`],
25//! and one whose issue selector names an issue the snapshot does not hold is
26//! [`TimeTotalState::UnknownIssue`] rather than an issue that recorded nothing.
27//!
28//! A malformed entry contributes **no number**: it is excluded from the valid total,
29//! counted in [`TimeTotal::malformed_count`] and reported by its own finding, and a
30//! total that dropped one can never be read as complete without the reader seeing
31//! the count ([`TimeTotal::is_partial`]) — the mirror of the WIP mission's "an
32//! uncountable estimate makes the total unknown, not zero" (C-005, research R10).
33//! The sum itself is accumulated in `i128`, so a store whose entries are each a
34//! representable `i64` cannot wrap the total into a wrong number.
35//!
36//! # The audit trail is History's
37//!
38//! Normal totals are the **current** projection: a deleted entry is simply absent,
39//! and its minutes are never added back. History is consulted only when the caller
40//! asks for it — [`TimeFilter::include_deleted`] on a total, or
41//! [`TimeSnapshot::audit`] — and then it is *reported*: how many prior values a
42//! deletion left behind, on which issues, by whom, and when
43//! ([`TimeDeletion`]). The prior value's own text stays in the event, because
44//! re-parsing the History grammar here would be a second owner of a grammar WP02
45//! already owns; this module reads only the entry id the grammar's own inverse
46//! ([`entry_id_in`]) recovers.
47//!
48//! # Read-only by construction
49//!
50//! Neither [`TimeSnapshot`] nor [`TimeTotal`] holds a store, a config or a writer,
51//! so there is no seam through which aggregation could mutate an issue: the numbers
52//! are derived from a slice a caller read, and the slice it was handed is untouched
53//! (C-001/C-008).
54
55use std::cmp::Ordering;
56use std::collections::{BTreeMap, BTreeSet};
57
58use crate::model::{HistoryEventKind, Issue, validate_date};
59use crate::time_tracking::diagnostics::{TimeDiagnostic, TimeDiagnosticKind};
60use crate::time_tracking::entry::TimeEntry;
61use crate::time_tracking::filters::{
62    PeriodPlacement, TimeFilter, canonical_identity, identity_cmp,
63};
64use crate::time_tracking::service::entry_id_in;
65
66/// The dimension a total is grouped by (T014).
67///
68/// The variants are ordered so that a breakdown comes out of a `BTreeMap` in the
69/// order a reader expects — everything first, then issues ascending, then identities,
70/// then unattributed entries, then dates ascending, undated last — without a second
71/// sort key to keep in step.
72///
73/// Identity groups compare through [`identity_cmp`], so `ada` and `Ada` are one
74/// group; [`Ord`] and [`PartialEq`] are written together here so that two groups that
75/// order equal also compare equal, which the crate's other `Ord` implementations are
76/// equally careful about ([`TimeEntry`] among them).
77#[derive(Debug, Clone)]
78pub enum TimeGroup {
79    /// The whole scope, ungrouped.
80    All,
81    /// One issue's entries.
82    Issue(u32),
83    /// One identity's entries, keyed by canonical identity.
84    Assignee(String),
85    /// Entries whose author names no identity, so they cannot be attributed to a
86    /// person. They are shown, not dropped.
87    Unattributed,
88    /// One calendar date's entries.
89    Date(String),
90    /// Entries no period can place: no date, or a date the calendar validator
91    /// refuses (the entry is diagnosed as `invalid_date` in the same total).
92    Undated,
93}
94
95impl TimeGroup {
96    /// A short label for the group, for a heading or a payload (C-006).
97    ///
98    /// One spelling for every surface: `all`, `#3`, `@ada`, `(unattributed)`,
99    /// `2026-03-01`, `(undated)`.
100    pub fn label(&self) -> String {
101        match self {
102            Self::All => "all".to_string(),
103            Self::Issue(number) => format!("#{number}"),
104            Self::Assignee(identity) => crate::assignee::assignee_display(identity),
105            Self::Unattributed => "(unattributed)".to_string(),
106            Self::Date(date) => date.clone(),
107            Self::Undated => "(undated)".to_string(),
108        }
109    }
110}
111
112impl PartialEq for TimeGroup {
113    fn eq(&self, other: &Self) -> bool {
114        self.cmp(other) == Ordering::Equal
115    }
116}
117
118impl Eq for TimeGroup {}
119
120impl PartialOrd for TimeGroup {
121    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
122        Some(self.cmp(other))
123    }
124}
125
126impl Ord for TimeGroup {
127    fn cmp(&self, other: &Self) -> Ordering {
128        match (self, other) {
129            (Self::All, Self::All) => Ordering::Equal,
130            (Self::All, _) => Ordering::Less,
131            (_, Self::All) => Ordering::Greater,
132            (Self::Issue(left), Self::Issue(right)) => left.cmp(right),
133            (Self::Issue(_), _) => Ordering::Less,
134            (_, Self::Issue(_)) => Ordering::Greater,
135            (Self::Assignee(left), Self::Assignee(right)) => identity_cmp(left, right),
136            (Self::Assignee(_), _) => Ordering::Less,
137            (_, Self::Assignee(_)) => Ordering::Greater,
138            (Self::Unattributed, Self::Unattributed) => Ordering::Equal,
139            (Self::Unattributed, _) => Ordering::Less,
140            (_, Self::Unattributed) => Ordering::Greater,
141            (Self::Date(left), Self::Date(right)) => left.cmp(right),
142            (Self::Date(_), Self::Undated) => Ordering::Less,
143            (Self::Undated, Self::Date(_)) => Ordering::Greater,
144            (Self::Undated, Self::Undated) => Ordering::Equal,
145        }
146    }
147}
148
149/// The explicit state of a [`TimeTotal`] (T015).
150///
151/// Derived from the total's own fields, never stored, so a surface can branch on a
152/// typed answer instead of inferring one from `0` — which is the whole point: a
153/// zero-minute total and an empty scope are different facts, and so are a refused
154/// filter and an unknown issue.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
156pub enum TimeTotalState {
157    /// The filter was refused before any entry was evaluated; the findings say why.
158    InvalidFilter,
159    /// The issue selector named an issue this snapshot does not hold.
160    UnknownIssue,
161    /// The scope selected no entry at all — no entry belonged to it, or a period
162    /// placed none of the entries it selected (a date outside the period is simply
163    /// not in it).
164    Nothing,
165    /// The scope selected entries and no usable number came back: every selected
166    /// entry was malformed, or the period could not place the valid ones it selected
167    /// because they carry no date. Never reported as zero, and never as `Nothing` —
168    /// this scope did select real entries (C-005).
169    Unknown,
170    /// At least one entry was counted; `total_minutes` is its sum, where `Some(0)`
171    /// means a zero was genuinely recorded. `is_partial` says whether some selected
172    /// entry was left out of it.
173    Recorded,
174}
175
176impl TimeTotalState {
177    /// The stable spelling every surface prints and every payload carries.
178    pub const fn as_str(self) -> &'static str {
179        match self {
180            Self::InvalidFilter => "invalid_filter",
181            Self::UnknownIssue => "unknown_issue",
182            Self::Nothing => "nothing",
183            Self::Unknown => "unknown",
184            Self::Recorded => "recorded",
185        }
186    }
187}
188
189/// A typed integer-minute aggregate for one scope (data-model §4).
190///
191/// Every number beside the total is a count a reader needs to know whether the total
192/// is the whole story: how many entries were **counted**, how many were **malformed**
193/// (excluded from the total, each with a finding), how many the period could not
194/// place because they carry no date, and — only when the filter asked for audit
195/// context — how many prior values a deletion left in History.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct TimeTotal {
198    /// The selector this total was evaluated under (data-model §4's `scope`).
199    pub filter: TimeFilter,
200    /// The dimension this total is grouped by.
201    pub group: TimeGroup,
202    /// The sum of the counted entries' minutes. `None` when nothing was counted;
203    /// `Some(0)` only when a zero was actually recorded.
204    pub total_minutes: Option<i128>,
205    /// Valid entries counted into the sum.
206    pub entry_count: usize,
207    /// Entries whose own fields are unusable. They contribute no number, and each has
208    /// its own finding.
209    pub malformed_count: usize,
210    /// Valid entries a period-bounded filter could not place because they carry no
211    /// date. Their value is unknown to the period, never zero (C-005).
212    pub undated_count: usize,
213    /// Prior values deletions left in History on the issues this total is about.
214    /// `Some` only when the filter asked for audit context (T017), and never part of
215    /// the sum.
216    ///
217    /// The set counted is the scope's own: an ungrouped total counts the issues the
218    /// filter's selector covers, which is exactly what [`TimeSnapshot::audit`]
219    /// reports for the same filter — so an issue whose current entries were all
220    /// deleted still reports the values History keeps, never a zero; a per-issue
221    /// group counts its issue; an identity or date group counts the issues its
222    /// entries were drawn from, because History's prior values are not re-parsed here
223    /// and a deletion cannot be attributed to an identity or a date.
224    pub deleted_count: Option<usize>,
225    /// Every finding, scope findings first, then per entry in index order (T016).
226    pub diagnostics: Vec<TimeDiagnostic>,
227}
228
229impl TimeTotal {
230    /// The explicit state of this total (T015).
231    pub fn state(&self) -> TimeTotalState {
232        if self.diagnostics.iter().any(|finding| {
233            finding.is_scope_finding() && finding.kind == TimeDiagnosticKind::UnknownIssue
234        }) {
235            return TimeTotalState::UnknownIssue;
236        }
237        if self
238            .diagnostics
239            .iter()
240            .any(TimeDiagnostic::is_scope_finding)
241        {
242            return TimeTotalState::InvalidFilter;
243        }
244        if self.total_minutes.is_some() {
245            TimeTotalState::Recorded
246        } else if self.malformed_count > 0 || self.undated_count > 0 {
247            TimeTotalState::Unknown
248        } else {
249            TimeTotalState::Nothing
250        }
251    }
252
253    /// Whether some selected entry was left out of the sum (T015).
254    ///
255    /// True when a malformed entry contributed no number, and when a period-bounded
256    /// scope selected a valid entry it could not place: in both cases the total is
257    /// not the whole story, and a reader must be shown the counts and findings before
258    /// the number means anything.
259    pub const fn is_partial(&self) -> bool {
260        self.malformed_count > 0 || self.undated_count > 0
261    }
262
263    /// A total with no numbers and the findings that refused it.
264    fn bare(filter: TimeFilter, group: TimeGroup, diagnostics: Vec<TimeDiagnostic>) -> Self {
265        Self {
266            filter,
267            group,
268            total_minutes: None,
269            entry_count: 0,
270            malformed_count: 0,
271            undated_count: 0,
272            deleted_count: None,
273            diagnostics,
274        }
275    }
276}
277
278/// One current entry, indexed with the issue it belongs to (T014).
279#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
280pub struct IndexedEntry {
281    /// The issue number the entry belongs to.
282    pub issue: u32,
283    /// The current entry itself.
284    pub entry: TimeEntry,
285    /// The entry's `author` in canonical identity form, resolved once when the
286    /// snapshot was built. `None` when the author names no identity: a named
287    /// criterion can never match it, and a breakdown shows such an entry under
288    /// [`TimeGroup::Unattributed`] rather than dropping it.
289    pub canonical_author: Option<String>,
290}
291
292/// One prior value a deletion left in History (T017).
293///
294/// `entry_id` is the id the event's own grammar inverse recovered. It is `Option`
295/// because a hand-edited `History` line can name an entry the grammar cannot read;
296/// such an event is still counted and still listed, so the audit never silently
297/// shrinks.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct TimeDeletion {
300    /// The issue whose History retains the value.
301    pub issue: u32,
302    /// The deleted entry's stable id, when the event's detail names one.
303    pub entry_id: Option<String>,
304    /// When the deletion was recorded, from the event itself.
305    pub deleted_at: String,
306    /// Who recorded the deletion, from the event itself.
307    pub deleted_by: String,
308}
309
310/// One issue's current-versus-History context (T017).
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct IssueTimeAudit {
313    /// The issue this context is about.
314    pub issue: u32,
315    /// The issue's current entries, whatever the filter's period or assignee criteria
316    /// select: the comparison this exists for is current against History.
317    pub current: usize,
318    /// The prior values deletions left in this issue's History, in History order.
319    pub deletions: Vec<TimeDeletion>,
320    /// How many corrections this issue's History retains; each keeps a prior value
321    /// too, which is why a correction is auditable rather than silent (C-004).
322    pub corrections: usize,
323}
324
325/// The explicit audit view: the current projection beside what History retains
326/// (T017).
327///
328/// Asking for this **is** the explicit request, so
329/// [`TimeFilter::include_deleted`] does not gate it. The filter's issue selector
330/// chooses which issues are described; its period and assignee criteria are not
331/// applied, because History's prior values are not re-parsed here and so a deletion
332/// cannot honestly be placed in a period or attributed to an author.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct TimeAudit {
335    /// The filter the audit was requested under.
336    pub filter: TimeFilter,
337    /// One context per issue the filter's selector covers, in issue order.
338    pub issues: Vec<IssueTimeAudit>,
339    /// Prior values deletions left in History across those issues.
340    pub deleted_count: usize,
341    /// Corrections those issues' History retains.
342    pub corrected_count: usize,
343    /// Findings about the request itself (a refused filter, an unknown issue).
344    pub diagnostics: Vec<TimeDiagnostic>,
345}
346
347/// One read-only index of current entries and retained time History (T014).
348#[derive(Debug, Clone, Default, PartialEq, Eq)]
349pub struct TimeSnapshot {
350    /// Every current entry, in deterministic order.
351    entries: Vec<IndexedEntry>,
352    /// Every issue number the snapshot holds, ascending — including issues with no
353    /// entry, so "this issue recorded nothing" stays distinguishable from "this
354    /// snapshot never held this issue".
355    issues: Vec<u32>,
356    /// The deletions each issue's History retains, by issue.
357    deletions: BTreeMap<u32, Vec<TimeDeletion>>,
358    /// The corrections each issue's History retains, by issue.
359    corrections: BTreeMap<u32, usize>,
360}
361
362impl TimeSnapshot {
363    /// Index the current entries and time History of the issues a caller holds
364    /// (T014).
365    ///
366    /// One pass over the slice: each entry is resolved once (including the identity
367    /// it is attributed to, which every later grouping and comparison reuses), and
368    /// each issue's time events are read once. Entries come out in data-model §6
369    /// order, so the same issues always produce the same bytes — which is what makes
370    /// CLI, TUI, MCP and portable output agree on them (NFR-003).
371    pub fn of(issues: &[Issue]) -> Self {
372        let mut entries = Vec::new();
373        let mut numbers: Vec<u32> = Vec::with_capacity(issues.len());
374        let mut deletions: BTreeMap<u32, Vec<TimeDeletion>> = BTreeMap::new();
375        let mut corrections: BTreeMap<u32, usize> = BTreeMap::new();
376        for issue in issues {
377            numbers.push(issue.number);
378            entries.extend(issue.time_entries.iter().map(|entry| IndexedEntry {
379                issue: issue.number,
380                canonical_author: canonical_identity(&entry.author),
381                entry: entry.clone(),
382            }));
383            let mut deleted = Vec::new();
384            let mut corrected = 0usize;
385            for event in issue.history.events() {
386                match event.kind {
387                    HistoryEventKind::TimeEntryDeleted => deleted.push(TimeDeletion {
388                        issue: issue.number,
389                        entry_id: entry_id_in(&event.detail).map(str::to_string),
390                        deleted_at: event.timestamp.clone(),
391                        deleted_by: event.actor.clone(),
392                    }),
393                    HistoryEventKind::TimeEntryUpdated => corrected += 1,
394                    _ => {}
395                }
396            }
397            if !deleted.is_empty() {
398                deletions.insert(issue.number, deleted);
399            }
400            if corrected > 0 {
401                corrections.insert(issue.number, corrected);
402            }
403        }
404        numbers.sort_unstable();
405        numbers.dedup();
406        entries.sort();
407        Self {
408            entries,
409            issues: numbers,
410            deletions,
411            corrections,
412        }
413    }
414
415    /// Every indexed entry, in the snapshot's deterministic order.
416    pub fn entries(&self) -> &[IndexedEntry] {
417        &self.entries
418    }
419
420    /// Every issue number the snapshot holds, ascending.
421    pub fn issues(&self) -> &[u32] {
422        &self.issues
423    }
424
425    /// Whether the snapshot holds this issue — with or without entries.
426    pub fn holds_issue(&self, number: u32) -> bool {
427        self.issues.binary_search(&number).is_ok()
428    }
429
430    /// The prior values deletions left in one issue's History, in History order.
431    pub fn deletions_on(&self, issue: u32) -> &[TimeDeletion] {
432        self.deletions
433            .get(&issue)
434            .map_or([].as_slice(), Vec::as_slice)
435    }
436
437    /// How many corrections one issue's History retains.
438    pub fn corrections_on(&self, issue: u32) -> usize {
439        self.corrections.get(&issue).copied().unwrap_or(0)
440    }
441
442    /// The issue numbers a filter's selector covers, in issue order (T017).
443    ///
444    /// The **selector** is the issue criterion alone: an identity or a period cannot
445    /// be applied to a deletion, because History's prior values are not re-parsed
446    /// here, so no number of this snapshot's issues is invented or dropped for them.
447    /// One definition of "the issues this scope covers" is what keeps
448    /// [`Self::audit`] and [`TimeTotal::deleted_count`] from disagreeing about the
449    /// same scope — including the case where every current entry an issue recorded
450    /// was deleted, which is exactly when the audit number is the only one there is.
451    fn scope_issues<'a>(&'a self, filter: &'a TimeFilter) -> impl Iterator<Item = u32> + 'a {
452        self.issues
453            .iter()
454            .copied()
455            .filter(move |number| filter.issue.is_none_or(|selected| selected == *number))
456    }
457
458    /// The prior values deletions left on the issues a filter's selector covers.
459    fn scope_deletions(&self, filter: &TimeFilter) -> usize {
460        self.scope_issues(filter)
461            .map(|number| self.deletions_on(number).len())
462            .sum()
463    }
464
465    /// The typed total for one scope (T015).
466    ///
467    /// A refused filter yields one total whose state is
468    /// [`TimeTotalState::InvalidFilter`] and whose findings are the refusals — no
469    /// entry is evaluated, so an invalid bound can never be read as "no bound". A
470    /// selector naming an issue this snapshot does not hold yields
471    /// [`TimeTotalState::UnknownIssue`] for the same reason.
472    pub fn total(&self, filter: &TimeFilter) -> TimeTotal {
473        if let Some(refused) = self.refusal(filter) {
474            return refused;
475        }
476        let matcher = Matcher::new(filter);
477        let mut totals = Totals::default();
478        for indexed in &self.entries {
479            if !matcher.matches(indexed) {
480                continue;
481            }
482            let placement = filter.place(indexed.entry.date.as_deref());
483            totals.fold(indexed, placement);
484        }
485        totals.into_total(
486            self,
487            filter.clone(),
488            TimeGroup::All,
489            DeletionScope::Selector,
490        )
491    }
492
493    /// Every requested scope, evaluated against this one snapshot (T014).
494    ///
495    /// The filters are answered in the order they were given and each is derived from
496    /// the same index, so a set of scopes costs no second scan, no second read and no
497    /// second opinion.
498    pub fn totals(&self, filters: &[TimeFilter]) -> Vec<TimeTotal> {
499        filters.iter().map(|filter| self.total(filter)).collect()
500    }
501
502    /// One total per issue the snapshot holds (T014, FR-003).
503    ///
504    /// Every issue appears, **including an issue holding no entry** — its total is
505    /// [`TimeTotalState::Nothing`] with zero counts, which is a real answer and not
506    /// an absence to hide. An entry whose date falls outside a period is still this
507    /// issue's entry, so a period-bounded breakdown keeps the issue and reports
508    /// nothing inside the period, rather than making the issue vanish.
509    pub fn by_issue(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
510        if let Some(refused) = self.refusal(filter) {
511            return vec![refused];
512        }
513        let matcher = Matcher::new(filter);
514        let mut groups: BTreeMap<u32, Totals> = BTreeMap::new();
515        for number in &self.issues {
516            if matcher.holds_issue(*number) {
517                groups.insert(*number, Totals::default());
518            }
519        }
520        for indexed in &self.entries {
521            if !matcher.matches(indexed) {
522                continue;
523            }
524            let placement = filter.place(indexed.entry.date.as_deref());
525            if let Some(totals) = groups.get_mut(&indexed.issue) {
526                totals.fold(indexed, placement);
527            }
528        }
529        groups
530            .into_iter()
531            .map(|(number, totals)| {
532                totals.into_total(
533                    self,
534                    filter.clone(),
535                    TimeGroup::Issue(number),
536                    DeletionScope::Issue(number),
537                )
538            })
539            .collect()
540    }
541
542    /// One total per identity that did work in scope (T014, FR-003).
543    ///
544    /// `ada` and `@Ada` are one identity, through the identity model the rest of the
545    /// crate uses; an entry whose author names no identity is shown under
546    /// [`TimeGroup::Unattributed`] rather than dropped.
547    pub fn by_assignee(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
548        if let Some(refused) = self.refusal(filter) {
549            return vec![refused];
550        }
551        let matcher = Matcher::new(filter);
552        let mut groups: BTreeMap<TimeGroup, Totals> = BTreeMap::new();
553        for indexed in &self.entries {
554            if !matcher.matches(indexed) {
555                continue;
556            }
557            let key = match &indexed.canonical_author {
558                Some(identity) => TimeGroup::Assignee(identity.clone()),
559                None => TimeGroup::Unattributed,
560            };
561            let placement = filter.place(indexed.entry.date.as_deref());
562            groups.entry(key).or_default().fold(indexed, placement);
563        }
564        groups
565            .into_iter()
566            .map(|(group, totals)| {
567                totals.into_total(self, filter.clone(), group, DeletionScope::Drawn)
568            })
569            .collect()
570    }
571
572    /// One total per calendar date in scope (T014, FR-003).
573    ///
574    /// A date outside a period gets **no group at all** — a group whose key is a date
575    /// the period excludes would contradict itself — while an entry the period cannot
576    /// place (no date, or a date the validator refuses) is shown under
577    /// [`TimeGroup::Undated`] and counted, so the breakdown never hides it.
578    pub fn by_date(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
579        if let Some(refused) = self.refusal(filter) {
580            return vec![refused];
581        }
582        let matcher = Matcher::new(filter);
583        let mut groups: BTreeMap<TimeGroup, Totals> = BTreeMap::new();
584        for indexed in &self.entries {
585            if !matcher.matches(indexed) {
586                continue;
587            }
588            let placement = filter.place(indexed.entry.date.as_deref());
589            if placement == PeriodPlacement::OutOfPeriod {
590                continue;
591            }
592            let key = match indexed.entry.date.as_deref() {
593                Some(date) if validate_date(date) => TimeGroup::Date(date.to_string()),
594                _ => TimeGroup::Undated,
595            };
596            groups.entry(key).or_default().fold(indexed, placement);
597        }
598        groups
599            .into_iter()
600            .map(|(group, totals)| {
601                totals.into_total(self, filter.clone(), group, DeletionScope::Drawn)
602            })
603            .collect()
604    }
605
606    /// The explicit audit view for one scope (T017).
607    pub fn audit(&self, filter: &TimeFilter) -> TimeAudit {
608        let problems = filter.problems();
609        if !problems.is_empty() {
610            return TimeAudit {
611                filter: filter.clone(),
612                issues: Vec::new(),
613                deleted_count: 0,
614                corrected_count: 0,
615                diagnostics: problems
616                    .iter()
617                    .map(TimeDiagnostic::from_filter_problem)
618                    .collect(),
619            };
620        }
621        let mut current: BTreeMap<u32, usize> = BTreeMap::new();
622        for indexed in &self.entries {
623            *current.entry(indexed.issue).or_default() += 1;
624        }
625        let mut issues = Vec::new();
626        let mut deleted_count = 0;
627        let mut corrected_count = 0;
628        for number in self.scope_issues(filter) {
629            let deletions = self.deletions_on(number).to_vec();
630            deleted_count += deletions.len();
631            corrected_count += self.corrections_on(number);
632            issues.push(IssueTimeAudit {
633                issue: number,
634                current: current.get(&number).copied().unwrap_or(0),
635                deletions,
636                corrections: self.corrections_on(number),
637            });
638        }
639        let mut diagnostics = Vec::new();
640        if let Some(number) = filter.issue
641            && !self.holds_issue(number)
642        {
643            diagnostics.push(TimeDiagnostic::unknown_issue(number));
644        }
645        TimeAudit {
646            filter: filter.clone(),
647            issues,
648            deleted_count,
649            corrected_count,
650            diagnostics,
651        }
652    }
653
654    /// The one total that refuses a scope instead of reporting numbers for it.
655    ///
656    /// A refused or unknown scope yields **one** ungrouped total carrying the
657    /// findings, never one invented group per issue: there is nothing to break down.
658    fn refusal(&self, filter: &TimeFilter) -> Option<TimeTotal> {
659        let problems = filter.problems();
660        if !problems.is_empty() {
661            return Some(TimeTotal::bare(
662                filter.clone(),
663                TimeGroup::All,
664                problems
665                    .iter()
666                    .map(TimeDiagnostic::from_filter_problem)
667                    .collect(),
668            ));
669        }
670        let number = filter.issue.filter(|number| !self.holds_issue(*number))?;
671        Some(TimeTotal::bare(
672            filter.clone(),
673            TimeGroup::All,
674            vec![TimeDiagnostic::unknown_issue(number)],
675        ))
676    }
677}
678
679/// The filter's entry test with its identity criterion resolved once (T014).
680///
681/// [`TimeFilter::selects`] canonicalizes per call, which is right for one entry and
682/// wrong for ten thousand: this is the same AND rule with the identity resolved once,
683/// so a pass over the index allocates nothing per entry.
684struct Matcher<'a> {
685    filter: &'a TimeFilter,
686    assignee: Option<String>,
687}
688
689impl<'a> Matcher<'a> {
690    fn new(filter: &'a TimeFilter) -> Self {
691        Self {
692            filter,
693            assignee: filter.canonical_assignee(),
694        }
695    }
696
697    fn holds_issue(&self, number: u32) -> bool {
698        self.filter.issue.is_none_or(|selected| selected == number)
699    }
700
701    fn matches(&self, indexed: &IndexedEntry) -> bool {
702        if !self.holds_issue(indexed.issue) {
703            return false;
704        }
705        let Some(criterion) = self.assignee.as_deref() else {
706            return true;
707        };
708        match indexed.canonical_author.as_deref() {
709            Some(author) => identity_cmp(author, criterion) == Ordering::Equal,
710            None => false,
711        }
712    }
713}
714
715/// Which issues a total's audit count is about (T017).
716///
717/// A total carries one number for "how many prior values a deletion left behind", and
718/// the number only means something beside the issue set it was counted over. An
719/// ungrouped total counts the filter's own selector — the very set
720/// [`TimeSnapshot::audit`] reports, so the two public methods cannot contradict each
721/// other, and an issue whose current entries were **all** deleted still reports the
722/// values History keeps instead of a zero. A per-issue group counts its one issue; an
723/// identity or date group cannot attribute a deletion to itself (History's prior
724/// values are not re-parsed), so it counts the issues its entries were drawn from.
725enum DeletionScope {
726    /// The issues the filter's selector covers.
727    Selector,
728    /// The single issue a per-issue group is about.
729    Issue(u32),
730    /// The issues a group's entries were drawn from.
731    Drawn,
732}
733
734/// The running counts and findings of one scope.
735#[derive(Default)]
736struct Totals {
737    minutes: i128,
738    entry_count: usize,
739    malformed_count: usize,
740    undated_count: usize,
741    diagnostics: Vec<TimeDiagnostic>,
742    /// The issues this scope drew an entry from, for the audit count (T017).
743    drawn: BTreeSet<u32>,
744}
745
746impl Totals {
747    /// One selected entry into the counts.
748    ///
749    /// Malformed first, always: an entry whose own fields are unusable contributes no
750    /// number wherever it is placed, and saying so is what keeps it out of the total
751    /// instead of adding a zero. Then the period placement, which only a valid entry
752    /// can reach [`PeriodPlacement::InPeriod`] under.
753    fn fold(&mut self, indexed: &IndexedEntry, placement: PeriodPlacement) {
754        self.drawn.insert(indexed.issue);
755        let problems = indexed.entry.problems();
756        if !problems.is_empty() {
757            self.malformed_count += 1;
758            self.diagnostics.extend(problems.iter().map(|problem| {
759                TimeDiagnostic::from_entry_problem(indexed.issue, &indexed.entry.id, problem)
760            }));
761            return;
762        }
763        let Some(minutes) = indexed.entry.minutes() else {
764            // `minutes()` is `None` exactly when the entry carries an
765            // `invalid_duration` problem, so this arm is unreachable for a
766            // problem-free entry: counting it as malformed keeps the partition total
767            // rather than letting a missing value become a zero (C-005).
768            self.malformed_count += 1;
769            self.diagnostics.push(
770                TimeDiagnostic::new(
771                    TimeDiagnosticKind::InvalidDuration,
772                    "the entry carries no usable `duration_minutes` value",
773                )
774                .for_entry(indexed.issue, &indexed.entry.id)
775                .in_field("duration_minutes"),
776            );
777            return;
778        };
779        match placement {
780            PeriodPlacement::InPeriod => {
781                self.minutes += i128::from(minutes);
782                self.entry_count += 1;
783            }
784            PeriodPlacement::Undated => self.undated_count += 1,
785            // An out-of-period entry is simply not in the period, and an unreadable
786            // date belongs to an entry that was already reported as malformed above.
787            PeriodPlacement::OutOfPeriod | PeriodPlacement::Unreadable => {}
788        }
789    }
790
791    /// Finish the scope into its DTO.
792    fn into_total(
793        self,
794        snapshot: &TimeSnapshot,
795        filter: TimeFilter,
796        group: TimeGroup,
797        deletion_scope: DeletionScope,
798    ) -> TimeTotal {
799        let total_minutes = (self.entry_count > 0).then_some(self.minutes);
800        let deleted_count = filter.include_deleted.then(|| match deletion_scope {
801            DeletionScope::Selector => snapshot.scope_deletions(&filter),
802            DeletionScope::Issue(number) => snapshot.deletions_on(number).len(),
803            DeletionScope::Drawn => self
804                .drawn
805                .iter()
806                .map(|number| snapshot.deletions_on(*number).len())
807                .sum(),
808        });
809        TimeTotal {
810            filter,
811            group,
812            total_minutes,
813            entry_count: self.entry_count,
814            malformed_count: self.malformed_count,
815            undated_count: self.undated_count,
816            deleted_count,
817            diagnostics: self.diagnostics,
818        }
819    }
820}