tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! The normalized time filter every surface selects with (mission
//! `per-issue-time-tracking-01M3C9A4`, WP03 T013).
//!
//! # One filter vocabulary, one combination rule
//!
//! [`TimeFilter`] names, all of them optional, an **issue**, an **assignee**, an
//! inclusive **calendar period** and an explicit audit context. Every criterion that
//! is named must match: the combination rule is **AND**, and a criterion that is not
//! named is not a constraint at all — a filter with nothing named selects every
//! entry, which is what `tix time total` with no flags means. The rule is stated
//! here and [`TimeFilter::selects`] is the one predicate a surface needs, so the
//! CLI, the TUI, MCP and portable output cannot disagree about what "assignee ada
//! from March 1 to March 31" selects (C-005/C-006, FR-003).
//!
//! # The period is a placement, not a scope
//!
//! The issue and the assignee decide *membership*; the calendar bounds decide where
//! an entry that is already a member is *placed* ([`TimeFilter::place`]). Splitting
//! the two is what lets an entry carrying no date be **counted and reported**
//! rather than silently dropped: [`PeriodPlacement::Undated`] is a fact an
//! aggregation counts, while a date outside the bounds is simply not in the period
//! (the filter's purpose). A date the calendar validator refuses is
//! [`PeriodPlacement::Unreadable`] — the entry is malformed and reported as such,
//! never read as "outside the period" (C-005).
//!
//! # Caller-supplied dates, never a clock
//!
//! The bounds are the caller's strings in the project's `YYYY-MM-DD` calendar shape,
//! checked by [`crate::model::validate_date`] — the same validator every
//! date-bearing field uses — and an invalid bound is reported before anything is
//! selected (data-model §3). This module reads **no clock**: "last week" is resolved
//! by the surface that knows what today is, and the aggregation only ever sees the
//! bounds it was handed. Comparisons are `&str` comparisons, which are chronological
//! for that fixed-width shape and need no date arithmetic.
//!
//! # Read-only by construction
//!
//! A filter holds strings and numbers, and it holds no reference to a store, a
//! config, a writer or a snapshot, so it has no seam through which it could mutate
//! anything (C-008). It is a value: two equal filters select equal entries, forever.

use std::cmp::Ordering;
use std::fmt;

use crate::assignee::parse_assignee;
use crate::model::validate_date;
use crate::time_tracking::entry::TimeEntry;

/// Where an entry's calendar date falls relative to a filter's period (T013).
///
/// Explicit rather than a boolean, because two of the four answers are facts an
/// aggregation must keep separate from "outside the period": an entry with no date
/// is **not** outside anything, and a date the validator refuses is a malformed
/// value rather than a date the period excludes (C-005).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeriodPlacement {
    /// The entry is in the period — every entry, when the filter names no bounds.
    InPeriod,
    /// The entry carries a valid date outside the inclusive bounds.
    OutOfPeriod,
    /// The filter names bounds and the entry carries no date, so no period can
    /// place it. The entry's value is still real; it is reported, never dropped.
    Undated,
    /// The filter names bounds and the entry's date is not a `YYYY-MM-DD` calendar
    /// date. The entry is malformed (`invalid_date`) and is diagnosed as such.
    Unreadable,
}

/// Why a [`TimeFilter`] cannot be used as written (T013).
///
/// The filter's own refusal vocabulary, field-aware, exactly as
/// [`crate::time_tracking::service::TimeMutationError`] is the mutation seam's:
/// data-model §3 requires invalid dates, identities and period bounds to be
/// *reported before aggregation*, and each variant here carries the offending value
/// plus the field it came from. [`crate::time_tracking::diagnostics::TimeDiagnostic`]
/// maps them onto the shared diagnostic DTO, so a refused filter reads the same
/// wherever it is rendered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeFilterError {
    /// A period bound is not a `YYYY-MM-DD` calendar date.
    InvalidDate {
        /// The bound's field name: `date_from` or `date_to`.
        field: &'static str,
        /// The value exactly as supplied.
        date: String,
    },
    /// `date_from` is later than `date_to`, so no date is in the period.
    InvertedPeriod {
        /// The lower bound, as supplied.
        date_from: String,
        /// The upper bound, as supplied.
        date_to: String,
    },
    /// The assignee criterion is blank, or holds no handle and no identity.
    InvalidAssignee {
        /// The value exactly as supplied.
        assignee: String,
    },
}

impl TimeFilterError {
    /// The stable, snake_case name a surface reports this refusal under.
    pub const fn kind(&self) -> &'static str {
        match self {
            Self::InvalidDate { .. } => "invalid_date",
            Self::InvertedPeriod { .. } => "invalid_period",
            Self::InvalidAssignee { .. } => "invalid_assignee",
        }
    }

    /// The field the refusal belongs to.
    pub const fn field(&self) -> &'static str {
        match self {
            Self::InvalidDate { field, .. } => field,
            Self::InvertedPeriod { .. } => "period",
            Self::InvalidAssignee { .. } => "assignee",
        }
    }

    /// The single-line explanation, without the kind prefix.
    pub fn message(&self) -> String {
        match self {
            Self::InvalidDate { field, date } => format!(
                "`{field}` {date:?} is not a `YYYY-MM-DD` calendar date (year >= 1970), so no \
                 period bound was applied"
            ),
            Self::InvertedPeriod { date_from, date_to } => format!(
                "`date_from` {date_from} is later than `date_to` {date_to}, so the period holds no \
                 date"
            ),
            Self::InvalidAssignee { assignee } => format!(
                "assignee {assignee:?} names no identity, so no entry can match the criterion"
            ),
        }
    }

    /// What the reader can do about it — the remedy half of the finding shape.
    pub const fn remedy(&self) -> &'static str {
        match self {
            Self::InvalidDate { .. } => {
                "write the bound as `YYYY-MM-DD` (year >= 1970), or drop it"
            }
            Self::InvertedPeriod { .. } => {
                "swap the bounds: the period is inclusive and its start must not be later than its \
                 end"
            }
            Self::InvalidAssignee { .. } => {
                "name an identity (`ada`, `@ada`, or `Ada Lovelace <ada@example.com>`), or drop \
                 the criterion"
            }
        }
    }
}

impl fmt::Display for TimeFilterError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.kind(), self.message())
    }
}

impl std::error::Error for TimeFilterError {}

/// An identity in the crate's canonical in-memory form, or `None` when the value
/// names nobody (T013).
///
/// [`parse_assignee`] is the crate's one identity parser (`@ada` → `ada`, a full
/// `Name <email>` kept verbatim, blank → `None`); reusing it rather than trimming
/// strings here is what keeps a filter, a grouping and a filesystem segment in
/// agreement about who `@ada` is.
pub fn canonical_identity(raw: &str) -> Option<String> {
    parse_assignee(raw)
}

/// Compare two **canonical** identities the way the crate's identity model does:
/// ASCII case-insensitively, so `ada` and `Ada` are one person — the same rule
/// [`crate::assignee::derive_segment`] uses to put them in one directory (T013).
///
/// The comparison is byte-wise and allocates nothing, which matters because
/// aggregations sort and group with it. Non-ASCII letters compare exactly; every
/// identity this crate writes is an ASCII handle or a `Name <email>` string.
pub fn identity_cmp(a: &str, b: &str) -> Ordering {
    let mut left = a.bytes().map(|byte| byte.to_ascii_lowercase());
    let mut right = b.bytes().map(|byte| byte.to_ascii_lowercase());
    loop {
        match (left.next(), right.next()) {
            (None, None) => return Ordering::Equal,
            (None, Some(_)) => return Ordering::Less,
            (Some(_), None) => return Ordering::Greater,
            (Some(x), Some(y)) => match x.cmp(&y) {
                Ordering::Equal => {}
                other => return other,
            },
        }
    }
}

/// The normalized, read-only issue/assignee/period selector (data-model §3).
///
/// Every field is optional and every named one is required to match — the AND rule
/// above. [`Self::include_deleted`] is the one field that selects nothing: it is the
/// explicit audit context, and it only ever *widens what is reported* about the
/// entries the other criteria already selected, never what is summed (T017).
///
/// The assignee criterion names **the identity that did the work** — the entry's
/// `author` — because that is the one identity a time entry carries (data-model §1).
/// The issue's own `assignee` is ownership, and this read-only module does not filter
/// on it, so a time operation can never appear to change who owns the issue (C-008).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TimeFilter {
    /// The stable issue number to select, or `None` for every issue.
    pub issue: Option<u32>,
    /// The identity that did the work, or `None` for every identity.
    pub assignee: Option<String>,
    /// The inclusive lower calendar bound, `YYYY-MM-DD`.
    pub date_from: Option<String>,
    /// The inclusive upper calendar bound, `YYYY-MM-DD`.
    pub date_to: Option<String>,
    /// Whether to report the deleted entries History retains (audit context).
    pub include_deleted: bool,
}

impl TimeFilter {
    /// A filter with no criteria: it selects every entry.
    pub const fn new() -> Self {
        Self {
            issue: None,
            assignee: None,
            date_from: None,
            date_to: None,
            include_deleted: false,
        }
    }

    /// Select one issue by its stable number.
    pub fn for_issue(mut self, number: u32) -> Self {
        self.issue = Some(number);
        self
    }

    /// Select the entries one identity did.
    pub fn for_assignee(mut self, assignee: impl Into<String>) -> Self {
        self.assignee = Some(assignee.into());
        self
    }

    /// Bound the period from below, inclusively.
    pub fn since(mut self, date: impl Into<String>) -> Self {
        self.date_from = Some(date.into());
        self
    }

    /// Bound the period from above, inclusively.
    pub fn until(mut self, date: impl Into<String>) -> Self {
        self.date_to = Some(date.into());
        self
    }

    /// Bound the period on both sides, inclusively.
    pub fn between(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
        self.date_from = Some(start.into());
        self.date_to = Some(end.into());
        self
    }

    /// Report the deleted entries History retains, too (audit context, T017).
    pub fn including_deleted(mut self) -> Self {
        self.include_deleted = true;
        self
    }

    /// Whether the filter names no criterion at all.
    pub const fn is_unfiltered(&self) -> bool {
        self.issue.is_none()
            && self.assignee.is_none()
            && self.date_from.is_none()
            && self.date_to.is_none()
    }

    /// Whether the filter names a calendar period.
    pub const fn has_period(&self) -> bool {
        self.date_from.is_some() || self.date_to.is_some()
    }

    /// Every reason the filter cannot be used as written, in field order; empty
    /// means valid.
    ///
    /// The bounds are checked with the crate's calendar validator and the assignee
    /// with the crate's identity parser, so "valid" here means the same thing it
    /// means everywhere else. An inverted period is only reported when both bounds
    /// are themselves valid: reporting both a bad bound and an inversion derived
    /// from it would name one mistake twice.
    pub fn problems(&self) -> Vec<TimeFilterError> {
        let mut problems = Vec::new();
        for (field, bound) in [("date_from", &self.date_from), ("date_to", &self.date_to)] {
            if let Some(date) = bound.as_deref()
                && !validate_date(date)
            {
                problems.push(TimeFilterError::InvalidDate {
                    field,
                    date: date.to_string(),
                });
            }
        }
        if let (Some(from), Some(to)) = (self.date_from.as_deref(), self.date_to.as_deref())
            && validate_date(from)
            && validate_date(to)
            && from > to
        {
            problems.push(TimeFilterError::InvertedPeriod {
                date_from: from.to_string(),
                date_to: to.to_string(),
            });
        }
        if let Some(assignee) = self.assignee.as_deref()
            && canonical_identity(assignee).is_none()
        {
            problems.push(TimeFilterError::InvalidAssignee {
                assignee: assignee.to_string(),
            });
        }
        problems
    }

    /// Refuse the filter unless every criterion is usable.
    pub fn validate(&self) -> Result<(), Vec<TimeFilterError>> {
        let problems = self.problems();
        if problems.is_empty() {
            Ok(())
        } else {
            Err(problems)
        }
    }

    /// Whether every criterion is usable.
    pub fn is_valid(&self) -> bool {
        self.problems().is_empty()
    }

    /// The assignee criterion in canonical identity form, resolved once.
    ///
    /// What [`Self::selects`] compares against, and what an aggregation should
    /// resolve once instead of re-parsing per entry (T014).
    pub fn canonical_assignee(&self) -> Option<String> {
        self.assignee.as_deref().and_then(canonical_identity)
    }

    /// Where `date` falls relative to this filter's period (T013).
    ///
    /// A filter that names no bounds places **every** date in the period, including
    /// an entry with no date and an entry whose date is unreadable: with no period
    /// there is nothing to fail to place, and the entry's own problems are reported
    /// by the aggregation that reads its fields.
    ///
    /// The bounds are assumed readable — [`Self::problems`] is checked before any
    /// aggregation, and [`Self::selects`] refuses an invalid filter outright. A
    /// malformed bound is never *read* here, so it can never be mistaken for "no
    /// bound" by a caller that validated its filter.
    pub fn place(&self, date: Option<&str>) -> PeriodPlacement {
        if !self.has_period() {
            return PeriodPlacement::InPeriod;
        }
        let Some(date) = date else {
            return PeriodPlacement::Undated;
        };
        if !validate_date(date) {
            return PeriodPlacement::Unreadable;
        }
        let after_from = self.date_from.as_deref().is_none_or(|from| date >= from);
        let before_to = self.date_to.as_deref().is_none_or(|to| date <= to);
        if after_from && before_to {
            PeriodPlacement::InPeriod
        } else {
            PeriodPlacement::OutOfPeriod
        }
    }

    /// Whether one entry of one issue is selected by the whole predicate.
    ///
    /// The AND rule, in one place: the issue criterion, then the assignee criterion,
    /// then the period placement. An **invalid criterion selects nothing** rather
    /// than being skipped: an unreadable bound must not behave as *no bound* and a
    /// blank identity must not match every author, because both would read a mistake
    /// as a wider scope than the caller asked for. [`Self::problems`] is where the
    /// reason is reported. For a pass over many entries resolve
    /// [`Self::canonical_assignee`] once instead: this convenience canonicalizes the
    /// criterion per call.
    pub fn selects(&self, issue: u32, entry: &TimeEntry) -> bool {
        if !self.is_valid() {
            return false;
        }
        if let Some(number) = self.issue
            && number != issue
        {
            return false;
        }
        if let Some(criterion) = self.canonical_assignee() {
            let Some(author) = canonical_identity(&entry.author) else {
                return false;
            };
            if identity_cmp(&author, &criterion) != Ordering::Equal {
                return false;
            }
        }
        self.place(entry.date.as_deref()) == PeriodPlacement::InPeriod
    }

    /// A short label for the scope, for a heading or a payload (C-006).
    ///
    /// One spelling for every surface: `issue #3`, `assignee @ada`,
    /// `period 2026-03-01..2026-03-31`, `audit`, joined by `, `, or `all` when the
    /// filter names nothing.
    pub fn describe(&self) -> String {
        let mut parts = Vec::new();
        if let Some(issue) = self.issue {
            parts.push(format!("issue #{issue}"));
        }
        if let Some(assignee) = self.canonical_assignee() {
            parts.push(format!(
                "assignee {}",
                crate::assignee::assignee_display(&assignee)
            ));
        }
        match (self.date_from.as_deref(), self.date_to.as_deref()) {
            (Some(from), Some(to)) => parts.push(format!("period {from}..{to}")),
            (Some(from), None) => parts.push(format!("period {from}..")),
            (None, Some(to)) => parts.push(format!("period ..{to}")),
            (None, None) => {}
        }
        if self.include_deleted {
            parts.push("audit".to_string());
        }
        if parts.is_empty() {
            return "all".to_string();
        }
        parts.join(", ")
    }
}

impl fmt::Display for TimeFilter {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.describe())
    }
}