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}