Skip to main content

tix/time_tracking/
filters.rs

1//! The normalized time filter every surface selects with (mission
2//! `per-issue-time-tracking-01M3C9A4`, WP03 T013).
3//!
4//! # One filter vocabulary, one combination rule
5//!
6//! [`TimeFilter`] names, all of them optional, an **issue**, an **assignee**, an
7//! inclusive **calendar period** and an explicit audit context. Every criterion that
8//! is named must match: the combination rule is **AND**, and a criterion that is not
9//! named is not a constraint at all — a filter with nothing named selects every
10//! entry, which is what `tix time total` with no flags means. The rule is stated
11//! here and [`TimeFilter::selects`] is the one predicate a surface needs, so the
12//! CLI, the TUI, MCP and portable output cannot disagree about what "assignee ada
13//! from March 1 to March 31" selects (C-005/C-006, FR-003).
14//!
15//! # The period is a placement, not a scope
16//!
17//! The issue and the assignee decide *membership*; the calendar bounds decide where
18//! an entry that is already a member is *placed* ([`TimeFilter::place`]). Splitting
19//! the two is what lets an entry carrying no date be **counted and reported**
20//! rather than silently dropped: [`PeriodPlacement::Undated`] is a fact an
21//! aggregation counts, while a date outside the bounds is simply not in the period
22//! (the filter's purpose). A date the calendar validator refuses is
23//! [`PeriodPlacement::Unreadable`] — the entry is malformed and reported as such,
24//! never read as "outside the period" (C-005).
25//!
26//! # Caller-supplied dates, never a clock
27//!
28//! The bounds are the caller's strings in the project's `YYYY-MM-DD` calendar shape,
29//! checked by [`crate::model::validate_date`] — the same validator every
30//! date-bearing field uses — and an invalid bound is reported before anything is
31//! selected (data-model §3). This module reads **no clock**: "last week" is resolved
32//! by the surface that knows what today is, and the aggregation only ever sees the
33//! bounds it was handed. Comparisons are `&str` comparisons, which are chronological
34//! for that fixed-width shape and need no date arithmetic.
35//!
36//! # Read-only by construction
37//!
38//! A filter holds strings and numbers, and it holds no reference to a store, a
39//! config, a writer or a snapshot, so it has no seam through which it could mutate
40//! anything (C-008). It is a value: two equal filters select equal entries, forever.
41
42use std::cmp::Ordering;
43use std::fmt;
44
45use crate::assignee::parse_assignee;
46use crate::model::validate_date;
47use crate::time_tracking::entry::TimeEntry;
48
49/// Where an entry's calendar date falls relative to a filter's period (T013).
50///
51/// Explicit rather than a boolean, because two of the four answers are facts an
52/// aggregation must keep separate from "outside the period": an entry with no date
53/// is **not** outside anything, and a date the validator refuses is a malformed
54/// value rather than a date the period excludes (C-005).
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum PeriodPlacement {
57    /// The entry is in the period — every entry, when the filter names no bounds.
58    InPeriod,
59    /// The entry carries a valid date outside the inclusive bounds.
60    OutOfPeriod,
61    /// The filter names bounds and the entry carries no date, so no period can
62    /// place it. The entry's value is still real; it is reported, never dropped.
63    Undated,
64    /// The filter names bounds and the entry's date is not a `YYYY-MM-DD` calendar
65    /// date. The entry is malformed (`invalid_date`) and is diagnosed as such.
66    Unreadable,
67}
68
69/// Why a [`TimeFilter`] cannot be used as written (T013).
70///
71/// The filter's own refusal vocabulary, field-aware, exactly as
72/// [`crate::time_tracking::service::TimeMutationError`] is the mutation seam's:
73/// data-model §3 requires invalid dates, identities and period bounds to be
74/// *reported before aggregation*, and each variant here carries the offending value
75/// plus the field it came from. [`crate::time_tracking::diagnostics::TimeDiagnostic`]
76/// maps them onto the shared diagnostic DTO, so a refused filter reads the same
77/// wherever it is rendered.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum TimeFilterError {
80    /// A period bound is not a `YYYY-MM-DD` calendar date.
81    InvalidDate {
82        /// The bound's field name: `date_from` or `date_to`.
83        field: &'static str,
84        /// The value exactly as supplied.
85        date: String,
86    },
87    /// `date_from` is later than `date_to`, so no date is in the period.
88    InvertedPeriod {
89        /// The lower bound, as supplied.
90        date_from: String,
91        /// The upper bound, as supplied.
92        date_to: String,
93    },
94    /// The assignee criterion is blank, or holds no handle and no identity.
95    InvalidAssignee {
96        /// The value exactly as supplied.
97        assignee: String,
98    },
99}
100
101impl TimeFilterError {
102    /// The stable, snake_case name a surface reports this refusal under.
103    pub const fn kind(&self) -> &'static str {
104        match self {
105            Self::InvalidDate { .. } => "invalid_date",
106            Self::InvertedPeriod { .. } => "invalid_period",
107            Self::InvalidAssignee { .. } => "invalid_assignee",
108        }
109    }
110
111    /// The field the refusal belongs to.
112    pub const fn field(&self) -> &'static str {
113        match self {
114            Self::InvalidDate { field, .. } => field,
115            Self::InvertedPeriod { .. } => "period",
116            Self::InvalidAssignee { .. } => "assignee",
117        }
118    }
119
120    /// The single-line explanation, without the kind prefix.
121    pub fn message(&self) -> String {
122        match self {
123            Self::InvalidDate { field, date } => format!(
124                "`{field}` {date:?} is not a `YYYY-MM-DD` calendar date (year >= 1970), so no \
125                 period bound was applied"
126            ),
127            Self::InvertedPeriod { date_from, date_to } => format!(
128                "`date_from` {date_from} is later than `date_to` {date_to}, so the period holds no \
129                 date"
130            ),
131            Self::InvalidAssignee { assignee } => format!(
132                "assignee {assignee:?} names no identity, so no entry can match the criterion"
133            ),
134        }
135    }
136
137    /// What the reader can do about it — the remedy half of the finding shape.
138    pub const fn remedy(&self) -> &'static str {
139        match self {
140            Self::InvalidDate { .. } => {
141                "write the bound as `YYYY-MM-DD` (year >= 1970), or drop it"
142            }
143            Self::InvertedPeriod { .. } => {
144                "swap the bounds: the period is inclusive and its start must not be later than its \
145                 end"
146            }
147            Self::InvalidAssignee { .. } => {
148                "name an identity (`ada`, `@ada`, or `Ada Lovelace <ada@example.com>`), or drop \
149                 the criterion"
150            }
151        }
152    }
153}
154
155impl fmt::Display for TimeFilterError {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(f, "{}: {}", self.kind(), self.message())
158    }
159}
160
161impl std::error::Error for TimeFilterError {}
162
163/// An identity in the crate's canonical in-memory form, or `None` when the value
164/// names nobody (T013).
165///
166/// [`parse_assignee`] is the crate's one identity parser (`@ada` → `ada`, a full
167/// `Name <email>` kept verbatim, blank → `None`); reusing it rather than trimming
168/// strings here is what keeps a filter, a grouping and a filesystem segment in
169/// agreement about who `@ada` is.
170pub fn canonical_identity(raw: &str) -> Option<String> {
171    parse_assignee(raw)
172}
173
174/// Compare two **canonical** identities the way the crate's identity model does:
175/// ASCII case-insensitively, so `ada` and `Ada` are one person — the same rule
176/// [`crate::assignee::derive_segment`] uses to put them in one directory (T013).
177///
178/// The comparison is byte-wise and allocates nothing, which matters because
179/// aggregations sort and group with it. Non-ASCII letters compare exactly; every
180/// identity this crate writes is an ASCII handle or a `Name <email>` string.
181pub fn identity_cmp(a: &str, b: &str) -> Ordering {
182    let mut left = a.bytes().map(|byte| byte.to_ascii_lowercase());
183    let mut right = b.bytes().map(|byte| byte.to_ascii_lowercase());
184    loop {
185        match (left.next(), right.next()) {
186            (None, None) => return Ordering::Equal,
187            (None, Some(_)) => return Ordering::Less,
188            (Some(_), None) => return Ordering::Greater,
189            (Some(x), Some(y)) => match x.cmp(&y) {
190                Ordering::Equal => {}
191                other => return other,
192            },
193        }
194    }
195}
196
197/// The normalized, read-only issue/assignee/period selector (data-model §3).
198///
199/// Every field is optional and every named one is required to match — the AND rule
200/// above. [`Self::include_deleted`] is the one field that selects nothing: it is the
201/// explicit audit context, and it only ever *widens what is reported* about the
202/// entries the other criteria already selected, never what is summed (T017).
203///
204/// The assignee criterion names **the identity that did the work** — the entry's
205/// `author` — because that is the one identity a time entry carries (data-model §1).
206/// The issue's own `assignee` is ownership, and this read-only module does not filter
207/// on it, so a time operation can never appear to change who owns the issue (C-008).
208#[derive(Debug, Clone, PartialEq, Eq, Default)]
209pub struct TimeFilter {
210    /// The stable issue number to select, or `None` for every issue.
211    pub issue: Option<u32>,
212    /// The identity that did the work, or `None` for every identity.
213    pub assignee: Option<String>,
214    /// The inclusive lower calendar bound, `YYYY-MM-DD`.
215    pub date_from: Option<String>,
216    /// The inclusive upper calendar bound, `YYYY-MM-DD`.
217    pub date_to: Option<String>,
218    /// Whether to report the deleted entries History retains (audit context).
219    pub include_deleted: bool,
220}
221
222impl TimeFilter {
223    /// A filter with no criteria: it selects every entry.
224    pub const fn new() -> Self {
225        Self {
226            issue: None,
227            assignee: None,
228            date_from: None,
229            date_to: None,
230            include_deleted: false,
231        }
232    }
233
234    /// Select one issue by its stable number.
235    pub fn for_issue(mut self, number: u32) -> Self {
236        self.issue = Some(number);
237        self
238    }
239
240    /// Select the entries one identity did.
241    pub fn for_assignee(mut self, assignee: impl Into<String>) -> Self {
242        self.assignee = Some(assignee.into());
243        self
244    }
245
246    /// Bound the period from below, inclusively.
247    pub fn since(mut self, date: impl Into<String>) -> Self {
248        self.date_from = Some(date.into());
249        self
250    }
251
252    /// Bound the period from above, inclusively.
253    pub fn until(mut self, date: impl Into<String>) -> Self {
254        self.date_to = Some(date.into());
255        self
256    }
257
258    /// Bound the period on both sides, inclusively.
259    pub fn between(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
260        self.date_from = Some(start.into());
261        self.date_to = Some(end.into());
262        self
263    }
264
265    /// Report the deleted entries History retains, too (audit context, T017).
266    pub fn including_deleted(mut self) -> Self {
267        self.include_deleted = true;
268        self
269    }
270
271    /// Whether the filter names no criterion at all.
272    pub const fn is_unfiltered(&self) -> bool {
273        self.issue.is_none()
274            && self.assignee.is_none()
275            && self.date_from.is_none()
276            && self.date_to.is_none()
277    }
278
279    /// Whether the filter names a calendar period.
280    pub const fn has_period(&self) -> bool {
281        self.date_from.is_some() || self.date_to.is_some()
282    }
283
284    /// Every reason the filter cannot be used as written, in field order; empty
285    /// means valid.
286    ///
287    /// The bounds are checked with the crate's calendar validator and the assignee
288    /// with the crate's identity parser, so "valid" here means the same thing it
289    /// means everywhere else. An inverted period is only reported when both bounds
290    /// are themselves valid: reporting both a bad bound and an inversion derived
291    /// from it would name one mistake twice.
292    pub fn problems(&self) -> Vec<TimeFilterError> {
293        let mut problems = Vec::new();
294        for (field, bound) in [("date_from", &self.date_from), ("date_to", &self.date_to)] {
295            if let Some(date) = bound.as_deref()
296                && !validate_date(date)
297            {
298                problems.push(TimeFilterError::InvalidDate {
299                    field,
300                    date: date.to_string(),
301                });
302            }
303        }
304        if let (Some(from), Some(to)) = (self.date_from.as_deref(), self.date_to.as_deref())
305            && validate_date(from)
306            && validate_date(to)
307            && from > to
308        {
309            problems.push(TimeFilterError::InvertedPeriod {
310                date_from: from.to_string(),
311                date_to: to.to_string(),
312            });
313        }
314        if let Some(assignee) = self.assignee.as_deref()
315            && canonical_identity(assignee).is_none()
316        {
317            problems.push(TimeFilterError::InvalidAssignee {
318                assignee: assignee.to_string(),
319            });
320        }
321        problems
322    }
323
324    /// Refuse the filter unless every criterion is usable.
325    pub fn validate(&self) -> Result<(), Vec<TimeFilterError>> {
326        let problems = self.problems();
327        if problems.is_empty() {
328            Ok(())
329        } else {
330            Err(problems)
331        }
332    }
333
334    /// Whether every criterion is usable.
335    pub fn is_valid(&self) -> bool {
336        self.problems().is_empty()
337    }
338
339    /// The assignee criterion in canonical identity form, resolved once.
340    ///
341    /// What [`Self::selects`] compares against, and what an aggregation should
342    /// resolve once instead of re-parsing per entry (T014).
343    pub fn canonical_assignee(&self) -> Option<String> {
344        self.assignee.as_deref().and_then(canonical_identity)
345    }
346
347    /// Where `date` falls relative to this filter's period (T013).
348    ///
349    /// A filter that names no bounds places **every** date in the period, including
350    /// an entry with no date and an entry whose date is unreadable: with no period
351    /// there is nothing to fail to place, and the entry's own problems are reported
352    /// by the aggregation that reads its fields.
353    ///
354    /// The bounds are assumed readable — [`Self::problems`] is checked before any
355    /// aggregation, and [`Self::selects`] refuses an invalid filter outright. A
356    /// malformed bound is never *read* here, so it can never be mistaken for "no
357    /// bound" by a caller that validated its filter.
358    pub fn place(&self, date: Option<&str>) -> PeriodPlacement {
359        if !self.has_period() {
360            return PeriodPlacement::InPeriod;
361        }
362        let Some(date) = date else {
363            return PeriodPlacement::Undated;
364        };
365        if !validate_date(date) {
366            return PeriodPlacement::Unreadable;
367        }
368        let after_from = self.date_from.as_deref().is_none_or(|from| date >= from);
369        let before_to = self.date_to.as_deref().is_none_or(|to| date <= to);
370        if after_from && before_to {
371            PeriodPlacement::InPeriod
372        } else {
373            PeriodPlacement::OutOfPeriod
374        }
375    }
376
377    /// Whether one entry of one issue is selected by the whole predicate.
378    ///
379    /// The AND rule, in one place: the issue criterion, then the assignee criterion,
380    /// then the period placement. An **invalid criterion selects nothing** rather
381    /// than being skipped: an unreadable bound must not behave as *no bound* and a
382    /// blank identity must not match every author, because both would read a mistake
383    /// as a wider scope than the caller asked for. [`Self::problems`] is where the
384    /// reason is reported. For a pass over many entries resolve
385    /// [`Self::canonical_assignee`] once instead: this convenience canonicalizes the
386    /// criterion per call.
387    pub fn selects(&self, issue: u32, entry: &TimeEntry) -> bool {
388        if !self.is_valid() {
389            return false;
390        }
391        if let Some(number) = self.issue
392            && number != issue
393        {
394            return false;
395        }
396        if let Some(criterion) = self.canonical_assignee() {
397            let Some(author) = canonical_identity(&entry.author) else {
398                return false;
399            };
400            if identity_cmp(&author, &criterion) != Ordering::Equal {
401                return false;
402            }
403        }
404        self.place(entry.date.as_deref()) == PeriodPlacement::InPeriod
405    }
406
407    /// A short label for the scope, for a heading or a payload (C-006).
408    ///
409    /// One spelling for every surface: `issue #3`, `assignee @ada`,
410    /// `period 2026-03-01..2026-03-31`, `audit`, joined by `, `, or `all` when the
411    /// filter names nothing.
412    pub fn describe(&self) -> String {
413        let mut parts = Vec::new();
414        if let Some(issue) = self.issue {
415            parts.push(format!("issue #{issue}"));
416        }
417        if let Some(assignee) = self.canonical_assignee() {
418            parts.push(format!(
419                "assignee {}",
420                crate::assignee::assignee_display(&assignee)
421            ));
422        }
423        match (self.date_from.as_deref(), self.date_to.as_deref()) {
424            (Some(from), Some(to)) => parts.push(format!("period {from}..{to}")),
425            (Some(from), None) => parts.push(format!("period {from}..")),
426            (None, Some(to)) => parts.push(format!("period ..{to}")),
427            (None, None) => {}
428        }
429        if self.include_deleted {
430            parts.push("audit".to_string());
431        }
432        if parts.is_empty() {
433            return "all".to_string();
434        }
435        parts.join(", ")
436    }
437}
438
439impl fmt::Display for TimeFilter {
440    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441        f.write_str(&self.describe())
442    }
443}