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}