Skip to main content

tix/search/
filter.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// A selectable structured-filter criterion.
5///
6/// [`FilterField::ALL`] is the single ordered field list the filter modal
7/// renders its rows from (risk: field/modal drift) and [`StructuredFilter`]
8/// evaluates over — adding a criterion (FR-011) is one more `Option<String>`
9/// field on [`StructuredFilter`] plus one variant / match arm here.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum FilterField {
12    Assignee,
13    Label,
14    Status,
15    Priority,
16    Cycle,
17    Milestone,
18    Reviewer,
19    StartDate,
20    DueDate,
21    /// An issue's **direct** parent, by selector (WP06 T024).
22    Parent,
23    /// An issue's resolved workflow category, by category key (WP08 T034).
24    ///
25    /// **Derived, not stored.** Unlike the nine above, this criterion matches
26    /// nothing an issue's file contains: there is no `category` frontmatter key
27    /// (research R2), and the value comes from the issue's `status` resolved
28    /// through the board's `category_map`. See [`FilterField::key`] for what
29    /// that means for diagnostics.
30    Category,
31    /// An issue's **project membership**, by resolved project id (WP06 T026).
32    ///
33    /// A **membership** criterion, not a text one. `project:<id>` asks whether the
34    /// issue's association list — `Issue::projects`, read through the shared
35    /// [`crate::projects::ProjectNode::project_ids`] accessor that
36    /// `ProjectIndex::build` itself reads — contains the referenced project. It is
37    /// deliberately **not** a substring test on the rendered frontmatter: that
38    /// would call an issue whose body mentions an id a member, and would match
39    /// `project:p-1` against an issue associated with `p-12`.
40    ///
41    /// An issue in two projects matches `project:A` **and** `project:B`
42    /// (recorded in `contracts/cli-surface.md` §3); an unassociated issue matches
43    /// no project criterion, and an id no association carries matches nothing.
44    Project,
45    /// An issue's **epic membership**, by resolved epic id (WP06 T028).
46    ///
47    /// A **membership** criterion, not a text one, and the exact analogue of
48    /// [`FilterField::Project`] for the epic registry. `epic:<id>` asks whether the
49    /// issue's reference list — `Issue::epics`, read through the shared
50    /// [`crate::epics::EpicNode::epic_ids`] accessor that
51    /// [`crate::epics::EpicIndex::build`] itself reads — contains the referenced
52    /// epic. It is deliberately **not** a substring test on the rendered
53    /// frontmatter: that would call an issue whose body mentions an id a member, and
54    /// would match `epic:e-1` against an issue referencing `e-12`.
55    ///
56    /// An issue in two epics matches `epic:A` **and** `epic:B` (recorded in
57    /// `contracts/cli-surface.md` §3); an unreferenced issue matches no epic
58    /// criterion, and an id no reference carries matches nothing.
59    ///
60    /// The **token** is the singular `epic:` while [`Self::key`] is the plural
61    /// `epics` the model serializes — the same split [`FilterField::Project`]
62    /// records, and for the same reason.
63    Epic,
64}
65
66impl FilterField {
67    /// The criteria in modal row order (research D-05).
68    ///
69    /// `Parent` is **appended** rather than interleaved: a row's position is a
70    /// user's muscle memory and a TUI test's golden index, and moving eight
71    /// existing rows to make room for a ninth helps nobody (T024 step 2).
72    ///
73    /// `Category` is **appended** after `Parent`, for the same reason T024 step 2
74    /// gave: a row's position is a user's muscle memory and the TUI's golden
75    /// index, and it is also the position a user reaches last. A category is the
76    /// one criterion a user cannot reach from the file itself, so it is the one
77    /// they will look up last.
78    ///
79    /// `Project` is **appended** after `Category` for the same reason T024 step 2
80    /// gave: a row's position is a user's muscle memory and the TUI's golden index.
81    /// Appending is also what keeps `next()`/`prev()` wrapping correct without
82    /// touching a single existing index — the arithmetic is a modulus of the
83    /// extended length, and the wrapping test asserts both ends.
84    ///
85    /// `Epic` is **appended** after `Project` (WP06 T028) for exactly the same
86    /// reason: a row's position is a user's muscle memory and the TUI's golden
87    /// index, and appending keeps `next()`/`prev()` correct as a modulus of the
88    /// extended length without moving a single existing row.
89    pub const ALL: [FilterField; 13] = [
90        FilterField::Assignee,
91        FilterField::Label,
92        FilterField::Status,
93        FilterField::Priority,
94        FilterField::Cycle,
95        FilterField::Milestone,
96        FilterField::Reviewer,
97        FilterField::StartDate,
98        FilterField::DueDate,
99        FilterField::Parent,
100        FilterField::Category,
101        FilterField::Project,
102        FilterField::Epic,
103    ];
104
105    pub fn label(self) -> &'static str {
106        match self {
107            FilterField::Assignee => "Assignee",
108            FilterField::Label => "Label",
109            FilterField::Status => "Status",
110            FilterField::Priority => "Priority",
111            FilterField::Cycle => "Cycle",
112            FilterField::Milestone => "Milestone",
113            FilterField::Reviewer => "Reviewer",
114            FilterField::StartDate => "Start date",
115            FilterField::DueDate => "Due date",
116            FilterField::Parent => "Parent",
117            FilterField::Category => "Category",
118            FilterField::Project => "Project",
119            FilterField::Epic => "Epic",
120        }
121    }
122
123    /// The serialized frontmatter key each criterion matches against
124    /// (`Issue::to_file_contents` emits these). Used by diagnostics.
125    ///
126    /// **For [`FilterField::Category`] this is the token name, not a frontmatter
127    /// key**, because there is no `category` key: the criterion is derived from the
128    /// issue's `status` through the board's `category_map` (research R2). T034
129    /// step 4 asked what to report here and warned that leaving a nonexistent key
130    /// in the diagnostics would be a quiet lie, so the honest spelling is
131    /// `category` — which is exactly what the user types, and what the grammar
132    /// parses — plus [`FilterField::is_derived`] for any surface that has to
133    /// *explain* the criterion rather than merely echo it.
134    ///
135    /// The distinction is not cosmetic. `key()` feeds two things that read very
136    /// differently: a hint bar, where `category:completed` is exactly right, and
137    /// an error, where "no issue has `category:completed`" would send a user
138    /// looking for a field that does not exist. [`is_derived`] is what keeps the
139    /// second from happening.
140    pub fn key(self) -> &'static str {
141        match self {
142            FilterField::Assignee => "assignee",
143            FilterField::Label => "labels",
144            FilterField::Status => "status",
145            FilterField::Priority => "priority",
146            FilterField::Cycle => "cycle",
147            FilterField::Milestone => "milestone",
148            FilterField::Reviewer => "reviewer",
149            FilterField::StartDate => "start_date",
150            FilterField::DueDate => "due_date",
151            // The exact key `Issue` serializes. T024 step 4 asks for a test that
152            // fails if this drifts from the model, because `key()` feeds the
153            // diagnostics: a mismatch makes `tix lint` point at a field that does
154            // not exist, with no error anywhere.
155            FilterField::Parent => "parent",
156            // The **token** name, and the only correct spelling here: the grammar
157            // parses `category:` and the hint bar echoes it. Not a frontmatter key,
158            // and `is_derived` is what stops it being read as one.
159            FilterField::Category => "category",
160            // The exact frontmatter key WP03's model serializes (`Issue::to_file_contents`
161            // emits `projects:` via `PROJECTS_KEY`). Unlike `category`, this **is** a
162            // real frontmatter key, so the diagnostics may name it honestly — and a
163            // test asserts the string equals the model's constant, so a rename on
164            // there breaks a test rather than silently breaking the filter.
165            FilterField::Project => "projects",
166            // The exact frontmatter key WP03's model serializes (`Issue::to_file_contents`
167            // emits `epics:` via [`crate::epics::EPICS_KEY`]). Named through the
168            // service constant rather than a second literal so the filter table and
169            // the file format cannot drift: a rename on the service side fails a test
170            // (and this arm) rather than silently breaking the filter.
171            FilterField::Epic => crate::epics::EPICS_KEY,
172        }
173    }
174
175    /// Whether this criterion is **derived** from other data rather than read off
176    /// a frontmatter field (WP08 T034 step 4).
177    ///
178    /// `Category` is the only one. It is derived from `status` through the board's
179    /// `category_map`, so a diagnostic that names it as a field points at a field
180    /// that does not exist. Any surface printing *why* a criterion matched, or why
181    /// it found nothing, must say so through this.
182    pub fn is_derived(self) -> bool {
183        matches!(self, FilterField::Category)
184    }
185
186    /// The criterion as a user should read it in an explanation: `category` on its
187    /// own, or `category (derived from status)` where the extra clause matters.
188    ///
189    /// Split from [`key`] so a caller has to choose. A hint bar that appends
190    /// "(derived from status)" on every keystroke is noise, and an error that
191    /// omits it sends the user hunting for a field; this hands each surface the
192    /// short form and lets the ones that explain pick the long one.
193    pub fn describe(self) -> String {
194        if self.is_derived() {
195            format!("{} (derived from status)", self.key())
196        } else {
197            self.key().to_string()
198        }
199    }
200
201    /// Row index of this criterion in [`FilterField::ALL`].
202    pub fn row(self) -> usize {
203        FilterField::ALL
204            .iter()
205            .position(|field| *field == self)
206            .expect("FilterField variants are all in ALL")
207    }
208
209    pub fn next(self) -> Self {
210        let all = FilterField::ALL;
211        all[(self.row() + 1) % all.len()]
212    }
213
214    pub fn prev(self) -> Self {
215        let all = FilterField::ALL;
216        all[(self.row() + all.len() - 1) % all.len()]
217    }
218
219    /// The criterion value when set *and* non-empty. An empty string is
220    /// treated as "no criterion" everywhere (canonical invariant), so the
221    /// filter modal can edit a slot without a live-match surprise.
222    pub fn value(self, filter: &StructuredFilter) -> Option<&str> {
223        let value = match self {
224            FilterField::Assignee => filter.assignee.as_deref(),
225            FilterField::Label => filter.label.as_deref(),
226            FilterField::Status => filter.status.as_deref(),
227            FilterField::Priority => filter.priority.as_deref(),
228            FilterField::Cycle => filter.cycle.as_deref(),
229            FilterField::Milestone => filter.milestone.as_deref(),
230            FilterField::Reviewer => filter.reviewer.as_deref(),
231            FilterField::StartDate => filter.start_date.as_deref(),
232            FilterField::DueDate => filter.due_date.as_deref(),
233            FilterField::Parent => filter.parent.as_deref(),
234            FilterField::Category => filter.category.as_deref(),
235            FilterField::Project => filter.project.as_deref(),
236            FilterField::Epic => filter.epic.as_deref(),
237        };
238        value.filter(|s| !s.is_empty())
239    }
240
241    /// Mutable access to the criterion buffer, materializing an empty one
242    /// when the criterion is unset (the filter modal types into it and
243    /// `StructuredFilter::normalize` promotes empty back to `None`).
244    pub fn value_mut(self, filter: &mut StructuredFilter) -> &mut String {
245        match self {
246            FilterField::Assignee => filter.assignee.get_or_insert_default(),
247            FilterField::Label => filter.label.get_or_insert_default(),
248            FilterField::Status => filter.status.get_or_insert_default(),
249            FilterField::Priority => filter.priority.get_or_insert_default(),
250            FilterField::Cycle => filter.cycle.get_or_insert_default(),
251            FilterField::Milestone => filter.milestone.get_or_insert_default(),
252            FilterField::Reviewer => filter.reviewer.get_or_insert_default(),
253            FilterField::StartDate => filter.start_date.get_or_insert_default(),
254            FilterField::DueDate => filter.due_date.get_or_insert_default(),
255            FilterField::Parent => filter.parent.get_or_insert_default(),
256            // The criterion is typed into the modal and validated when the query is
257            // **run** (see `resolve_category_criterion`), not per keystroke: the
258            // modal edits a buffer, and rejecting a half-typed `comp` because it is
259            // not yet a category would make the row unusable. The service has no
260            // business being called from a text field.
261            FilterField::Category => filter.category.get_or_insert_default(),
262            // Same reasoning as `Category` above: the modal edits a buffer, and the
263            // criterion is judged when the query **runs** (see `project_matches`),
264            // not per keystroke. A half-typed project id is not a project, and the
265            // service has no business being called from a text field.
266            FilterField::Project => filter.project.get_or_insert_default(),
267            // Same reasoning as `Project` above: the modal edits a buffer, and the
268            // criterion is judged when the query **runs** (see `epic_matches`), not
269            // per keystroke. A half-typed epic id is not an epic, and the service
270            // has no business being called from a text field.
271            FilterField::Epic => filter.epic.get_or_insert_default(),
272        }
273    }
274
275    /// Clear the criterion back to `None`.
276    pub fn unset(self, filter: &mut StructuredFilter) {
277        match self {
278            FilterField::Assignee => filter.assignee = None,
279            FilterField::Label => filter.label = None,
280            FilterField::Status => filter.status = None,
281            FilterField::Priority => filter.priority = None,
282            FilterField::Cycle => filter.cycle = None,
283            FilterField::Milestone => filter.milestone = None,
284            FilterField::Reviewer => filter.reviewer = None,
285            FilterField::StartDate => filter.start_date = None,
286            FilterField::DueDate => filter.due_date = None,
287            FilterField::Parent => filter.parent = None,
288            FilterField::Category => filter.category = None,
289            FilterField::Project => filter.project = None,
290            FilterField::Epic => filter.epic = None,
291        }
292    }
293
294    /// Whether this criterion is a **parent reference** rather than a text field.
295    ///
296    /// The distinction drives two things: the free-typing path is bypassed in
297    /// favour of the issue picker (WP06 T026), and `evaluate` compares exactly
298    /// rather than by substring, because a number must not match a longer number
299    /// that merely contains it.
300    pub fn is_reference(self) -> bool {
301        matches!(self, FilterField::Parent)
302    }
303
304    /// The criterion's **query token**, as the grammar parses it and the hint bar
305    /// should echo it.
306    ///
307    /// For every criterion but [`FilterField::Project`] and
308    /// [`FilterField::Epic`] this is [`Self::key`] — the two spellings coincide
309    /// (`status:`, `labels:`, `parent:` the token and the frontmatter key alike).
310    /// Those two are the places they differ, and deliberately: the **token** is the
311    /// singular `project:`/`epic:` the parser accepts and the modal's help
312    /// advertises, while the **key** is the plural `projects`/`epics` WP03's model
313    /// serializes. A hint bar echoing `projects:p-1` would advertise a token
314    /// [`query_matches`] does not parse, which is a key that looks usable and does
315    /// nothing — the exact failure `key()`'s own doc warns about for diagnosis.
316    pub fn token(self) -> &'static str {
317        match self {
318            FilterField::Project => "project",
319            FilterField::Epic => "epic",
320            _ => self.key(),
321        }
322    }
323
324    /// Whether this criterion is a **project membership** test rather than a text
325    /// field (WP06 T026).
326    ///
327    /// Kept separate from [`Self::is_reference`] even though both compare exactly:
328    /// a parent is a *number* on the issue's own frontmatter and can be rewritten
329    /// into the query, while a project is a *membership* the file's bytes answer
330    /// only indirectly (the association list is a YAML block, not a scalar), so the
331    /// criterion travels in the [`StructuredFilter`] and is judged by
332    /// [`StructuredFilter::project_matches`]. The surfaces use this predicate to
333    /// route the row to the project picker rather than the issue picker.
334    pub fn is_project(self) -> bool {
335        matches!(self, FilterField::Project)
336    }
337
338    /// Whether this criterion is an **epic membership** test rather than a text
339    /// field (WP06 T028).
340    ///
341    /// The exact analogue of [`Self::is_project`]: a reference list is a YAML block
342    /// the rendered bytes answer only indirectly, so the criterion travels in the
343    /// [`StructuredFilter`] and is judged by [`StructuredFilter::epic_matches`]. Kept
344    /// a separate predicate from `is_project` because the two name different
345    /// registries and different pickers — collapsing them would make the surfaces
346    /// offer a project picker for an epic row.
347    pub fn is_epic(self) -> bool {
348        matches!(self, FilterField::Epic)
349    }
350
351    /// Whether this criterion holds a date (picker-only, no free typing).
352    pub fn is_date(self) -> bool {
353        matches!(self, FilterField::StartDate | FilterField::DueDate)
354    }
355}
356
357/// Typed, extensible structured filter (FR-007..FR-010, FR-011).
358///
359/// Every criterion is optional; an `None`/empty criterion narrows nothing.
360/// Criteria match **case-insensitively** against the serialized corpus text
361/// (the same haystack [`fuzzy_score`] runs over), following the existing
362/// fuzzy matcher's `to_lowercase()` behavior — this keeps the predicate a
363/// pure, config-independent function of the corpus (WP02 runs in parallel
364/// with WP01 for exactly that reason).
365#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
366pub struct StructuredFilter {
367    pub assignee: Option<String>,
368    pub label: Option<String>,
369    pub status: Option<String>,
370    pub priority: Option<String>,
371    pub cycle: Option<String>,
372    pub milestone: Option<String>,
373    pub reviewer: Option<String>,
374    pub start_date: Option<String>,
375    pub due_date: Option<String>,
376    pub overdue: Option<bool>,
377    /// The referenced parent, as a **resolved canonical issue id**.
378    ///
379    /// An id, not the selector the user typed, because graph-bearing frontmatter
380    /// stores canonical ids. Resolution happens once, at the command layer, where
381    /// the store is available; matching then stays a pure string comparison and
382    /// `query_matches` keeps its store-free signature.
383    ///
384    /// Direct children only, and by construction: an issue's `parent:` field *is*
385    /// its direct parent, so a grandchild's file names its own parent and never
386    /// matches an ancestor criterion. A transitive implementation would have to
387    /// walk the chain explicitly, and this way there is nothing to get wrong.
388    pub parent: Option<String>,
389    /// A workflow **category key** (WP08 T035), never a column name.
390    ///
391    /// A category key rather than a column, because several columns can share one
392    /// category and the whole point of the criterion is to select across that
393    /// boundary: `category:completed` on a board with `shipped`, `done` and
394    /// `released` all complete returns every one of them, where `status:done`
395    /// returns one.
396    ///
397    /// Validated through the service's `from_key` when the query runs
398    /// ([`resolve_category_criterion`]), so this never holds a value the
399    /// vocabulary does not contain. The TUI modal types into it unchecked — see
400    /// [`FilterField::value_mut`] for why that is the right layer to do it at.
401    pub category: Option<String>,
402    /// A resolved **project id** (WP06 T026/T027), or a reference no project
403    /// resolves to.
404    ///
405    /// Judged by [`Self::project_matches`] against the issue's association list —
406    /// never by [`Self::evaluate`]'s substring loop, because the association list
407    /// is a YAML block rather than a scalar line and a substring would match an id
408    /// mentioned in a body.
409    ///
410    /// [`resolve_filter_criteria`] canonicalizes a `project:<selector>` token into
411    /// this field, resolving an id or a name through the project service. A
412    /// reference the registry does not know is kept **verbatim** rather than
413    /// refused: the documented behaviour is that an unknown project id matches
414    /// nothing (T027 step 6), and `tix search` must not turn a typo into a hard
415    /// error where the filter grammar accepts every other unknown value. Matching
416    /// then simply finds no member, which is the empty result the user can see.
417    ///
418    /// An issue in two projects matches `project:A` and `project:B`; the criterion
419    /// narrows to the union of one project's members per query token, exactly like
420    /// every other single-valued criterion.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub project: Option<String>,
423    /// A resolved **epic id** (WP06 T028/T029), or a reference no epic resolves
424    /// to.
425    ///
426    /// The epic analogue of [`Self::project`], judged by [`Self::epic_matches`]
427    /// against the issue's `epics` reference list — never by [`Self::evaluate`]'s
428    /// substring loop, because the list is a YAML block rather than a scalar line
429    /// and a substring would match an id mentioned in a body.
430    ///
431    /// [`resolve_filter_criteria`] canonicalizes an `epic:<selector>` token into
432    /// this field, resolving an id or a name through the epic service
433    /// ([`crate::epics::resolve_epic_id`]). A reference the registry does not know is
434    /// kept **verbatim** rather than refused: the documented behaviour is that an
435    /// unknown epic id matches nothing (T029 step 6), mirroring `project:`.
436    ///
437    /// An issue in two epics matches `epic:A` and `epic:B`; the criterion narrows to
438    /// the union of one epic's members per query token, exactly like every other
439    /// single-valued criterion.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub epic: Option<String>,
442    /// The columns a `scope:` token selected (WP02 T010).
443    ///
444    /// A set of **column names**, not a name, because the columns are not
445    /// knowable from the query: `scope:triage` means "the columns `[triage]`
446    /// names", and only the config can answer that — the same reason `parent:`
447    /// and `category:` are resolved rather than matched from bytes. Filled by
448    /// [`resolve_filter_criteria`] through
449    /// [`crate::triage::inbox::scope_columns`], and judged by
450    /// [`Self::scope_matches`], which needs the issue the way `category` does.
451    ///
452    /// Deliberately **not** a [`FilterField`]: the filter modal has no row for
453    /// it and no user can type a scope into it, and appending a variant would
454    /// change `FilterField::ALL`'s length — which the modal's row layout, its
455    /// click mapping and its own tests all count. The extensibility promise on
456    /// [`FilterField`] is about criteria a user can enter; this is a resolved
457    /// selection a *token* produces, and `overdue` already shows a
458    /// `StructuredFilter` field that is not a modal row.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub scope: Option<Vec<String>>,
461    /// Whether the issue is **archived** (issue #1492), from an
462    /// `archived:true|false` token.
463    ///
464    /// A **derived** criterion, like `overdue` and `scope`, and for the same
465    /// reason: `archived` is not a frontmatter key. The marker is the leading dot
466    /// of the issue's filename, mirrored onto [`Issue::archived`], so nothing in
467    /// the serialized bytes can answer the question and the token is lifted out
468    /// by [`resolve_filter_criteria`] into this field, then judged by
469    /// [`Self::archived_matches`], which has the issue.
470    ///
471    /// `None` (no token) narrows nothing.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub archived: Option<bool>,
474}
475
476impl StructuredFilter {
477    /// The active (set, non-empty) criteria, in [`FilterField::ALL`] order.
478    pub fn criteria(&self) -> impl Iterator<Item = (FilterField, &str)> {
479        FilterField::ALL
480            .into_iter()
481            .filter_map(move |field| field.value(self).map(|value| (field, value)))
482    }
483
484    /// True when at least one criterion is active. When false the board must
485    /// render unchanged (no criteria → pass-through).
486    ///
487    /// The `scope` criterion is counted here even though it is not a
488    /// [`FilterField`] and so never appears in [`Self::criteria`]: a query that
489    /// selected rows is active however it selected them, and omitting it would
490    /// make `has_active_criteria` disagree with [`Self::evaluate_issue`] about
491    /// whether the same filter narrowed anything.
492    pub fn has_active_criteria(&self) -> bool {
493        self.criteria().next().is_some() || self.scope.is_some() || self.archived.is_some()
494    }
495
496    /// AND predicate over the serialized corpus text (FR-012, C-002).
497    ///
498    /// Applies *every* active criterion; the first miss short-circuits the
499    /// entry to `false`. The caller (WP03's `build_filtered`) invokes this
500    /// once per corpus entry, giving **one linear O(entries) pass** (NFR-001)
501    /// — `haystack` is lowercased once and each active criterion is a single
502    /// substring `contains` on that pre-lowered text, with no nested
503    /// field×corpus loops.
504    ///
505    /// Matching is case-insensitive and substring-based, consistently across
506    /// assignee/label/status/priority, exactly as WP02's prompt specifies
507    /// (T009). The stricter per-criterion "equals"/"contains-in-labels" rules
508    /// in `contracts/filtering.md` would require re-parsing the issue; on the
509    /// serialized corpus the substring rule is the single consistent gate.
510    pub fn evaluate(&self, haystack: &str) -> bool {
511        let hay = haystack.to_lowercase();
512        // The parent criterion is **not** a substring test, and is handled before
513        // the generic loop rather than inside it.
514        //
515        // Two reasons. First, a substring test over the whole haystack would match
516        // an issue whose *body* mentions the number, or whose `parent: 17` contains
517        // `1` — so `parent:1` would return the children of #17 as well. Second, the
518        // generic loop would also be reached for an issue with **no** `parent:` key
519        // at all, where `hay.contains("1")` is true for any issue whose text
520        // mentions 1. Every other criterion is a text field where substring is the
521        // right semantic; a reference is a value.
522        if let Some(number) = &self.parent
523            && field_value(haystack, "parent") != Some(number.as_str())
524        {
525            return false;
526        }
527        for (field, value) in self.criteria() {
528            // `parent` was already compared exactly above, and `category` cannot be
529            // compared here at all — see `evaluate_issue`. `project` is the same: a
530            // membership the rendered bytes answer only as a YAML block, judged by
531            // `project_matches` where the issue is in hand.
532            if field.is_reference() || field.is_derived() || field.is_project() || field.is_epic() {
533                continue;
534            }
535            if !hay.contains(&value.to_lowercase()) {
536                return false;
537            }
538        }
539        if let Some(cycle) = &self.cycle
540            && !hay.contains(&cycle.to_lowercase())
541        {
542            return false;
543        }
544        if let Some(milestone) = &self.milestone
545            && !hay.contains(&milestone.to_lowercase())
546        {
547            return false;
548        }
549        if let Some(reviewer) = &self.reviewer
550            && !hay.contains(&reviewer.to_lowercase())
551        {
552            return false;
553        }
554        if let Some(start_date) = &self.start_date
555            && !hay.contains(start_date)
556        {
557            return false;
558        }
559        if let Some(due_date) = &self.due_date
560            && !hay.contains(due_date)
561        {
562            return false;
563        }
564        if self.overdue == Some(true) && !is_overdue_haystack(haystack) {
565            return false;
566        }
567        true
568    }
569
570    /// The full structured predicate for an issue, including the criteria
571    /// [`Self::evaluate`] structurally cannot judge.
572    ///
573    /// WP08 T035. [`evaluate`] takes a haystack and is therefore store-free; the
574    /// category criterion needs the issue's `status` **and** the board's
575    /// `category_map`, so it is judged here instead, where both are available.
576    /// The two halves AND together, exactly as `evaluate` ANDs its own criteria.
577    ///
578    /// `text` is passed in rather than re-serialized from `issue`, because
579    /// `Corpus` already holds the exact bytes every issue was matched against and
580    /// a second `to_file_contents()` per issue per keystroke is measurable work
581    /// for an identical string.
582    pub fn evaluate_issue(
583        &self,
584        issue: &Issue,
585        text: &str,
586        resolution: &crate::workflow::CategoryResolution,
587    ) -> bool {
588        self.evaluate(text)
589            && self.category_matches(issue, resolution)
590            && self.scope_matches(issue)
591            && self.archived_matches(issue)
592            && self.project_matches(issue)
593            && self.epic_matches(issue)
594    }
595
596    /// Whether `issue`'s association list contains the referenced project
597    /// (WP06 T027).
598    ///
599    /// The membership predicate, and the **only** place a `project:` criterion is
600    /// judged. It reads the issue's association list through
601    /// [`crate::projects::ProjectNode::project_ids`] — the same accessor
602    /// `ProjectIndex::build` reads — so the two rules cannot drift at the accessor
603    /// level: there is one definition of "what the issue stores", and this reads it
604    /// rather than re-deriving it (C-006). Nothing here resolves a name, validates an
605    /// id, or crawls the registry; [`resolve_filter_criteria`] did that once, through
606    /// the service.
607    ///
608    /// **Raw-string match, deliberately, and it is the repair affordance.** A stored
609    /// association whose project the registry no longer holds (a `tix project delete`
610    /// left the reference behind — C-005 retains it) **still matches** here: the user
611    /// must be able to *find* the issues carrying the deleted id, because
612    /// `tix project remove <stale-id>` (and `tix edit --remove-project`) is how the
613    /// reference is repaired, and it takes the id as stored. The board's **partition**
614    /// deliberately differs: an unresolved id is not a project, so such an issue is
615    /// bucketed `(no project)` (see `ProjectPartition`). The two are a chosen
616    /// asymmetry, not a drift: the filter answers "which issues carry this string?",
617    /// the partition answers "which project owns this issue?" — and the second has no
618    /// answer for an id nothing resolves to. `tix lint` names the same references.
619    ///
620    /// **Exact**, not substring: `p-1` must not match an association with `p-12`,
621    /// and an issue whose *body* mentions an id is not a member. Case-sensitive
622    /// too — a resolved id is a canonical registry key, and a raw one is compared as
623    /// the file spells it, which is what a repair has to match.
624    ///
625    /// The multi-membership rule (T027 step 3, recorded in
626    /// `contracts/cli-surface.md` §3): an issue associated with two projects
627    /// matches either criterion, because the check is a membership test, not a
628    /// first-value read. An unassociated issue matches nothing, and an id no
629    /// association carries — including one the registry does not know — matches
630    /// nothing.
631    pub fn project_matches(&self, issue: &Issue) -> bool {
632        let Some(want) = self.project.as_deref().filter(|s| !s.is_empty()) else {
633            return true;
634        };
635        crate::projects::ProjectNode::project_ids(issue)
636            .iter()
637            .any(|id| id == want)
638    }
639
640    /// Whether `issue`'s epic reference list contains the referenced epic
641    /// (WP06 T029).
642    ///
643    /// The membership predicate, and the **only** place an `epic:` criterion is
644    /// judged. It reads the issue's references through [`crate::epics::EpicNode::epic_ids`]
645    /// — the same accessor [`crate::epics::EpicIndex::build`] reads, so the filter
646    /// and the service's membership index cannot drift at the accessor level: there
647    /// is one definition of "what the issue stores" (C-002), and this reads it
648    /// rather than re-deriving it. Nothing here resolves a name, validates an id, or
649    /// crawls the registry; [`resolve_filter_criteria`] did that once, through the
650    /// service.
651    ///
652    /// **Raw-string match, deliberately, and it is the repair affordance.** A stored
653    /// reference whose epic the registry no longer holds (a `tix epic delete` left
654    /// the reference behind — C-005 retains it) **still matches** here: the user must
655    /// be able to *find* the issues carrying the deleted id, because
656    /// `tix epic remove <stale-id>` (and `tix edit --remove-epic`) is how the
657    /// reference is repaired, and it takes the id as stored. The board's **partition**
658    /// deliberately differs: an unresolved id is not an epic, so such an issue is
659    /// bucketed `(no epic)` (see `crate::search::epic_bucket_label`). The two are a
660    /// chosen asymmetry, not a drift, exactly as `project_matches` records.
661    ///
662    /// **Exact**, not substring: `e-1` must not match a reference to `e-12`, and an
663    /// issue whose *body* mentions an id is not a member. Case-sensitive too — a
664    /// resolved id is a canonical registry key, and a raw one is compared as the
665    /// file spells it, which is what a repair has to match.
666    ///
667    /// The multi-membership rule (T029 step 3, recorded in
668    /// `contracts/cli-surface.md` §3): an issue referencing two epics matches either
669    /// criterion, because the check is a membership test, not a first-value read. An
670    /// unreferenced issue matches nothing, and an id no reference carries —
671    /// including one the registry does not know — matches nothing.
672    pub fn epic_matches(&self, issue: &Issue) -> bool {
673        let Some(want) = self.epic.as_deref().filter(|s| !s.is_empty()) else {
674            return true;
675        };
676        crate::epics::EpicNode::epic_ids(issue)
677            .iter()
678            .any(|id| id == want)
679    }
680
681    /// Whether `issue`'s archived state matches the `archived:` criterion
682    /// (issue #1492).
683    ///
684    /// Judged here rather than in [`Self::evaluate`] because the archived flag is
685    /// derived from the *filename* and never appears in the haystack; `None`
686    /// narrows nothing, and a set value is an exact `bool` comparison —
687    /// `archived:false` selects the active issues, `archived:true` the archived
688    /// ones.
689    pub fn archived_matches(&self, issue: &Issue) -> bool {
690        match self.archived {
691            None => true,
692            Some(want) => issue.archived == want,
693        }
694    }
695
696    /// Whether `issue` is in one of the columns the `scope:` token selected.
697    ///
698    /// WP02 T010. The same three-way reasoning as [`Self::category_matches`],
699    /// in reverse: a scope is a **membership set**, so an issue either has a
700    /// status in it or does not, and there is no category to default. `None` (no
701    /// token) passes everything — an unset criterion narrows nothing — and a set
702    /// that fails to contain `issue.status` fails closed, because a scope the
703    /// issue is not in is precisely what was asked for.
704    ///
705    /// Judged here rather than in [`Self::evaluate`] for the reason that
706    /// function gives: `evaluate` has a haystack and no issue, and the issue's
707    /// status is not reliably *in* the haystack in a form this can compare —
708    /// `status: triage` is a line of frontmatter, but a scope is a set of
709    /// columns and substring-matching the set against the file would call an
710    /// issue whose title contains `todo` a member of the `todo` column.
711    pub fn scope_matches(&self, issue: &Issue) -> bool {
712        match &self.scope {
713            None => true,
714            Some(columns) => columns.iter().any(|c| issue.status.is(c)),
715        }
716    }
717
718    /// Whether `issue` is in the requested category (WP08 T035).
719    ///
720    /// Three cases, and the third is the one the surfaces already distinguish:
721    ///
722    /// 1. **A mapped column** → its category, from the service's resolution.
723    /// 2. **A configured but unmapped column** → the **safe default**, because
724    ///    that is what it resolves to. T035 step 3 asks for this explicitly, and it
725    ///    is what keeps the criterion consistent with `tix workflow show`, which
726    ///    reports the same default for the same column.
727    /// 3. **A column the board does not configure** → matches **nothing**. There
728    ///    is no category to report: `resolve` is `None`, `tix list --show category`
729    ///    prints `(unmapped)`, and MCP returns `category: null` (WP07). A criterion
730    ///    has to agree with the surfaces that show the same fact, and silently
731    ///    folding this into the safe default would make `category:unstarted` return
732    ///    issues every other surface call unmapped.
733    ///
734    /// The unknown-category case is *not* handled here and cannot be: a value the
735    /// vocabulary does not contain is an error at the command layer
736    /// ([`resolve_category_criterion`]), not a match decision here. A criterion
737    /// that reached this function unchecked compares against a key nothing
738    /// resolves to and therefore matches nothing — the same empty result an
739    /// unknown `status:` gives, which is the established behaviour for a bad value
740    /// in a haystack criterion.
741    pub fn category_matches(
742        &self,
743        issue: &Issue,
744        resolution: &crate::workflow::CategoryResolution,
745    ) -> bool {
746        let Some(want) = self.category.as_deref().filter(|s| !s.is_empty()) else {
747            return true;
748        };
749        match resolution.resolve(issue.status.display()) {
750            Some(actual) => actual.key() == want,
751            // Case 3. `None` only for a column this board does not configure.
752            None => false,
753        }
754    }
755
756    /// Trim every criterion; values that trim to empty become `None` (the
757    /// canonical "unset" state). Used by the filter modal's commit path.
758    pub fn normalize(&mut self) {
759        for field in FilterField::ALL {
760            let empty = field.value_mut(self).trim().is_empty();
761            if empty {
762                field.unset(self);
763            } else {
764                let value = field.value_mut(self);
765                let trimmed = value.trim().to_string();
766                *value = trimmed;
767            }
768        }
769    }
770}
771
772impl std::fmt::Display for StructuredFilter {
773    /// Diagnostics for the hint bar: `assignee:alice, status:todo`, or
774    /// `(no active criteria)`.
775    ///
776    /// Built as a list rather than written field by field, because there are
777    /// now three sources of a part — the [`FilterField`] criteria, an active
778    /// `scope`, and `overdue` — and the first version returned early when the
779    /// *criteria* were empty. A scope-only filter would then have printed
780    /// `(no active criteria)` while actively filtering, which is the hint bar
781    /// telling the user the opposite of what the board is doing.
782    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783        let mut parts: Vec<String> = self
784            .criteria()
785            .map(|(field, value)| format!("{}:{value}", field.token()))
786            .collect();
787        if let Some(columns) = &self.scope {
788            parts.push(format!("scope:{}", columns.join(",")));
789        }
790        if let Some(archived) = self.archived {
791            parts.push(format!("archived:{archived}"));
792        }
793        if self.overdue == Some(true) {
794            parts.push("overdue:true".to_string());
795        }
796        if parts.is_empty() {
797            return write!(f, "(no active criteria)");
798        }
799        write!(f, "{}", parts.join(", "))
800    }
801}
802
803/// Composite predicate: the `/` fuzzy text layer **and** the structured
804/// filter both apply (FR-013).
805///
806/// - empty `query` → structured filter only;
807/// - empty structured filter → text query only;
808/// - both active → an entry must satisfy **both** layers.
809///
810/// Zero matches is a valid board, never an error (consumers render empty
811/// columns).
812pub fn matches(query: &str, filter: &StructuredFilter, haystack: &str) -> bool {
813    let text_matches = query_matches(query, haystack);
814    text_matches && filter.evaluate(haystack)
815}
816
817/// The full predicate for one corpus entry, including the derived criteria
818/// (WP08 T035, WP02 T010).
819///
820/// **Every** consumer should call this rather than [`matches`]. `matches` cannot
821/// judge a `category:` or `scope:` criterion — both need the issue (and the
822/// board's map, for `category`), and it has neither — and a consumer that calls
823/// it directly would therefore **silently ignore** an active category or scope
824/// criterion: the query would return the unfiltered set and read as "every issue
825/// is in that category/scope". One composition point is what keeps the CLI, the
826/// TUI board, and `tix search` from disagreeing, which is the property the
827/// module header exists to protect (C-005).
828///
829/// `text` is the corpus's own bytes for this entry, so no caller re-serializes.
830pub fn matches_issue(
831    query: &str,
832    filter: &StructuredFilter,
833    issue: &Issue,
834    text: &str,
835    resolution: &crate::workflow::CategoryResolution,
836) -> bool {
837    query_matches(query, text) && filter.evaluate_issue(issue, text, resolution)
838}
839
840/// Resolve a `parent:<selector>` query token to the canonical id it names.
841///
842/// Matching happens against an issue's rendered frontmatter, which stores the
843/// canonical id. The user types a *selector*, so the command layer resolves it
844/// once and rewrites the query to the stored spelling. A number remains an input
845/// alias; it is not the identity substituted into the graph criterion.
846///
847/// The substitution is textual on the **query**, not on the results: `parent:5`
848/// becomes `parent:<canonical-id>`. That keeps `query_matches` and `evaluate`
849/// store-free and gives the same comparison to canonical and number inputs.
850///
851/// A selector that resolves to nothing is an **error**, not an empty result: a user
852/// who typed `parent:Epicc` has made a typo, and "no issues found" is a different
853/// answer from "there is no such issue". Every other command's selector handling
854/// errors the same way.
855///
856/// Returns the query unchanged when it carries no `parent:` token, so a caller can
857/// use the result unconditionally.
858pub fn resolve_parent_criterion(store: &crate::storage::Store, query: &str) -> Result<String> {
859    let mut out: Vec<String> = Vec::with_capacity(8);
860    let mut touched = false;
861    for token in query.split_whitespace() {
862        let Some((key, value)) = parse_token(token) else {
863            out.push(token.to_string());
864            continue;
865        };
866        if !key.eq_ignore_ascii_case("parent") {
867            out.push(token.to_string());
868            continue;
869        }
870        let target = crate::commands::parse_selector(store, value)?;
871        // The store's own message, propagated as-is. The first version wrapped it in
872        // a fresh `NotFound`, and the user saw `not found: not found: issue matching
873        // …` — the same class of doubled prefix the relationship renderer fixed in
874        // WP03. One message, from one place.
875        let issue = store.get(target).map_err(|_| {
876            crate::error::TixError::NotFound(format!(
877                "issue matching '{value}' by number, filename or title"
878            ))
879        })?;
880        let canonical = crate::id::canonical_ref(&issue);
881        // The rewrite may be applied to either a current file (canonical id) or a
882        // pre-migration file (numeric alias). Carry both accepted spellings inside
883        // the one token; `query_matches` treats the pipe-separated set as OR.
884        let value = canonical.as_number().map_or_else(
885            || canonical.to_string(),
886            |number| format!("{canonical}|{number}"),
887        );
888        out.push(format!("parent:{value}"));
889        touched = true;
890    }
891    if !touched {
892        return Ok(query.to_string());
893    }
894    Ok(out.join(" "))
895}
896
897/// **Lift** the `category:<value>` criterion out of `query`, validating the value
898/// against the project's closed vocabulary (WP08 T035).
899///
900/// Returns `(query-without-the-token, Some(canonical_key))`. "Lift", not "rewrite":
901/// the token is **removed** and the criterion travels in the
902/// [`StructuredFilter`] instead, because that is the only shape that works. The
903/// first version rewrote `category:Completed` to `category:completed` and left the
904/// token in the string, so [`query_matches`] still met a `category` key, still
905/// refused it, and the filter matched **nothing** — while the canonicalization test
906/// beside it passed. Two green results and an empty board.
907///
908/// The validation this closes is the one a substring matcher cannot do. A bad
909/// `status:` value is a legitimate test that matches nothing; a bad *category* is a
910/// typo in a closed set of at most six words, and "0 issues found" is a silent dead
911/// end where one line names the words that would have worked.
912///
913/// Checked against the **project's** `[categories] order` rather than
914/// [`crate::config::CATEGORY_KEYS`] because `order` may be a **subset**: a project
915/// declaring `["backlog", "active", "completed", "canceled"]` has said its
916/// vocabulary is four words, and accepting `unstarted` would return results from a
917/// category it never declared. The error names that project's list.
918///
919/// `None` when the query carries no `category:` token, so a caller can use the
920/// result unconditionally. Same shape and same reason as
921/// [`resolve_parent_criterion`]: the matchers are pure string comparisons over a
922/// store-free haystack, so anything needing the config is resolved once, in the one
923/// layer that has it.
924pub(super) fn lift_category_criterion(
925    config: &crate::config::Config,
926    query: &str,
927) -> crate::error::Result<(String, Option<String>)> {
928    let mut out: Vec<String> = Vec::with_capacity(8);
929    let mut category = None;
930    for token in query.split_whitespace() {
931        let Some((key, value)) = parse_token(token) else {
932            out.push(token.to_string());
933            continue;
934        };
935        if !key.eq_ignore_ascii_case("category") {
936            out.push(token.to_string());
937            continue;
938        }
939        let lowered = value.to_lowercase();
940        if !config.categories.contains(&lowered) {
941            return Err(crate::error::TixError::InvalidInput(format!(
942                "unknown category '{value}' — this project's categories are: {}",
943                config.categories.order.join(", ")
944            )));
945        }
946        // `contains` and `from_key` read the same six words, so this cannot fail
947        // after the check above. `from_key` is still what produces the canonical
948        // spelling rather than trusting the user's casing, and it is the same
949        // function `WorkflowCategory::key` is compared against when matching.
950        category = Some(
951            crate::workflow::WorkflowCategory::from_key(&lowered)
952                .expect("categories.order is validated against the global vocabulary at load")
953                .key()
954                .to_string(),
955        );
956        // Dropped, not pushed: `out` is the query the store-free matcher will see.
957    }
958    Ok((out.join(" "), category))
959}
960
961/// Resolve a `project:<selector>` query token to a canonical project id
962/// (WP06 T027).
963///
964/// Same shape as [`lift_category_criterion`], and for the same reason: the
965/// association list is a YAML block, so a store-free matcher has no scalar to
966/// compare and leaving the token in the string would have [`query_matches`]
967/// refuse it and return an empty result that reads as "no matches".
968///
969/// Resolution goes through the **service** ([`crate::projects::resolve_project_id`]),
970/// so a name resolves to the same id `tix project show <name>` names and the id
971/// grammar is the registry's, not this function's. The canonical id is what
972/// travels in [`StructuredFilter::project`].
973///
974/// A selector the registry does not know is kept **verbatim** rather than
975/// refused. Every other command errors on an unknown selector, but the contract
976/// for this criterion is that an unknown project id matches **nothing**
977/// (T027 step 6) — so a typo yields the visible empty result rather than a hard
978/// error, and matching finds no member because no association can carry a
979/// reference the registry never issued. The typed reference is retained as the
980/// needle (not lowercased, not trimmed beyond what `parse_token` did) so the
981/// comparison stays an exact one against the stored ids.
982///
983/// Returns the query with every `project:` token removed, so a caller can use the
984/// result unconditionally. The last token wins when several are present, exactly
985/// as `category:` behaves.
986pub(super) fn lift_project_criterion(
987    config: &crate::config::Config,
988    query: &str,
989) -> (String, Option<String>) {
990    let mut out: Vec<String> = Vec::with_capacity(8);
991    let mut project = None;
992    for token in query.split_whitespace() {
993        let Some((key, value)) = parse_token(token) else {
994            out.push(token.to_string());
995            continue;
996        };
997        if !key.eq_ignore_ascii_case("project") {
998            out.push(token.to_string());
999            continue;
1000        }
1001        project = Some(
1002            crate::projects::resolve_project_id(config, value)
1003                .map(|id| id.to_string())
1004                .unwrap_or_else(|_| value.to_string()),
1005        );
1006        // Dropped, not pushed: `out` is the query the store-free matcher will see.
1007    }
1008    (out.join(" "), project)
1009}
1010
1011/// Resolve an `epic:<selector>` query token to a canonical epic id (WP06 T029).
1012///
1013/// The epic twin of [`lift_project_criterion`], and the same shape for the same
1014/// reason: the reference list is a YAML block, so a store-free matcher has no
1015/// scalar to compare and leaving the token in the string would have
1016/// [`query_matches`] refuse it and return an empty result that reads as "no
1017/// matches".
1018///
1019/// Resolution goes through the **service** ([`crate::epics::resolve_epic_id`]), so a
1020/// name resolves to the same id `tix epic show <name>` names and the id grammar is
1021/// the registry's, not this function's. The canonical id is what travels in
1022/// [`StructuredFilter::epic`].
1023///
1024/// A selector the registry does not know is kept **verbatim** rather than refused,
1025/// exactly as `project:` does: an unknown epic id matches **nothing** (T029 step 6)
1026/// — so a typo yields the visible empty result rather than a hard error, and
1027/// matching finds no member because no reference can carry an id the registry never
1028/// issued. The typed reference is retained as the needle so the comparison stays an
1029/// exact one against the stored ids.
1030///
1031/// Returns the query with every `epic:` token removed, so a caller can use the
1032/// result unconditionally. The last token wins when several are present, exactly as
1033/// `project:` behaves.
1034pub(super) fn lift_epic_criterion(
1035    config: &crate::config::Config,
1036    query: &str,
1037) -> (String, Option<String>) {
1038    let mut out: Vec<String> = Vec::with_capacity(8);
1039    let mut epic = None;
1040    for token in query.split_whitespace() {
1041        let Some((key, value)) = parse_token(token) else {
1042            out.push(token.to_string());
1043            continue;
1044        };
1045        if !key.eq_ignore_ascii_case("epic") {
1046            out.push(token.to_string());
1047            continue;
1048        }
1049        // The resolve-or-keep-verbatim classification is the service's one owner
1050        // (`epics::dereference_needle`), not a fourth spelling of it (WP09 item B).
1051        epic = Some(crate::epics::dereference_needle(&config.epics, value));
1052        // Dropped, not pushed: `out` is the query the store-free matcher will see.
1053    }
1054    (out.join(" "), epic)
1055}
1056
1057/// The group-section label for one issue's epic membership (WP06 T029).
1058///
1059/// The epic analogue of [`crate::projects::project_bucket_label`], and the recorded
1060/// partition decision (T029 step 4, `contracts/cli-surface.md` §3): one bucket per
1061/// issue — the single epic **id** when the issue references exactly one resolved
1062/// epic, `(multiple epics)` when it references several, and `(no epic)` when it
1063/// references none (including a reference no registry entry resolves).
1064///
1065/// Duplication is the road not taken, for the reason the project partition records:
1066/// one issue would become two rows, and the board's cursor, hit-testing and
1067/// per-column counts are all index-into-one-list arithmetic, so a card that appeared
1068/// twice would need a second identity the renderer does not have. The **filter**
1069/// still matches an issue in two epics under either id
1070/// ([`StructuredFilter::epic_matches`]), so the union semantics are not lost where
1071/// they matter.
1072pub fn epic_bucket_label(ids: &[String]) -> String {
1073    match ids {
1074        [] => NO_EPIC_BUCKET.to_string(),
1075        [only] => only.clone(),
1076        _ => MULTIPLE_EPICS_BUCKET.to_string(),
1077    }
1078}
1079
1080/// The labelled bucket for an issue in no epic (WP06 T029, WP09 item C).
1081///
1082/// A constant rather than a literal so the board, `tix list --group epic`/
1083/// `--sort epic` and `tix status --group epic` cannot spell the empty bucket two
1084/// ways; the project partition has the same pair.
1085pub const NO_EPIC_BUCKET: &str = "(no epic)";
1086
1087/// The labelled bucket for an issue resolved into several epics (WP06 T029, WP09
1088/// item C).
1089///
1090/// One bucket rather than a duplicate per epic, for the reason
1091/// [`crate::projects::MULTIPLE_PROJECTS_BUCKET`] records: a listing's cursor,
1092/// hit-testing and per-column counts are index-into-one-list arithmetic.
1093pub const MULTIPLE_EPICS_BUCKET: &str = "(multiple epics)";
1094
1095/// Resolve a `scope:<name>` query token to the columns it selects (WP02 T010).
1096///
1097/// Same shape as [`lift_category_criterion`], and for the same reason: the
1098/// columns a scope names are a **config** fact, so a store-free matcher cannot
1099/// reach them, and leaving the token in the string would have [`query_matches`]
1100/// refuse it and return an empty result that reads as "no matches".
1101///
1102/// One token rather than a new grammar. `scope:` is the `key:value` shape every
1103/// other criterion already uses, resolved through the same entry point, with no
1104/// new operator, no list syntax and no precedence — the module's own extension
1105/// promise ("adding a criterion is one more field on `StructuredFilter` plus one
1106/// match arm"), minus the `FilterField` variant, which would change the modal's
1107/// row count (see [`StructuredFilter::scope`]).
1108///
1109/// A token the project cannot honour is an **error**, not an empty selection:
1110/// see [`crate::triage::inbox::scope_columns`].
1111///
1112/// Returns the query with every `scope:` token removed, so a caller can use the
1113/// result unconditionally.
1114pub(super) fn lift_scope_criterion(
1115    config: &crate::config::Config,
1116    query: &str,
1117) -> crate::error::Result<(String, Option<Vec<String>>)> {
1118    let mut out: Vec<String> = Vec::with_capacity(8);
1119    let mut scope = None;
1120    for token in query.split_whitespace() {
1121        let Some((key, value)) = parse_token(token) else {
1122            out.push(token.to_string());
1123            continue;
1124        };
1125        if !key.eq_ignore_ascii_case("scope") {
1126            out.push(token.to_string());
1127            continue;
1128        }
1129        // Validated *and* resolved in one call, so a caller cannot keep a token
1130        // it failed to check. The last one wins, as `category:` does.
1131        scope = Some(crate::triage::inbox::scope_columns(config, value)?);
1132        // Dropped, not pushed: `out` is the query the store-free matcher will see.
1133    }
1134    Ok((out.join(" "), scope))
1135}
1136
1137/// Resolve a `--filter` / query string into the **pair** a match needs: the
1138/// canonicalized query, and the criteria the store-free matchers cannot judge
1139/// (WP08 T035).
1140///
1141/// Every consumer that takes a filter string should call this and hand both parts
1142/// to [`matches_issue`]. One function rather than two calls, because the failure
1143/// mode of getting it half-right is silent: a query whose `category:` token was
1144/// validated but not canonicalized into the structured criterion still matches
1145/// **nothing** (the token matcher refuses it), and a query that was canonicalized
1146/// but never validated accepts a category this project did not declare. Neither
1147/// error is visible in a return value.
1148///
1149/// `parent:` is resolved here too. It was always resolved at the command layer —
1150/// it is the same shape of problem — but in four separate call sites, and the
1151/// `category:` work would have made that four more. One entry point means a fifth
1152/// surface gets both.
1153///
1154/// Returns the query unchanged when it carries neither token, so a caller can use
1155/// the result unconditionally.
1156pub fn resolve_filter_criteria(
1157    store: &crate::storage::Store,
1158    query: &str,
1159) -> Result<(String, StructuredFilter)> {
1160    let query = resolve_parent_criterion(store, query)?;
1161    // `parent:` resolver **rewrites** its token — a selector becomes a canonical
1162    // id, and the haystack holds that id. `category:` **lifts** its token out. The
1163    // asymmetry is not an oversight: the parent criterion is compared against a
1164    // rendered frontmatter field, so it can stay in the string, while the category
1165    // criterion has no field to compare against and must arrive as a structured
1166    // value. Both are resolved here so no call site has to know which is which.
1167    // `scope:` is a `category:` in kind — a selection nothing in the file bytes
1168    // can answer — and is lifted the same way (WP02 T010).
1169    let (query, category) = lift_category_criterion(&store.config, &query)?;
1170    let (query, scope) = lift_scope_criterion(&store.config, &query)?;
1171    let (query, archived) = lift_archived_criterion(&query)?;
1172    // `project:` is lifted rather than rewritten, unlike `parent:`: the association
1173    // list is a YAML block, so there is no scalar in the haystack to rewrite the
1174    // token **into**. Resolution is best-effort (see `lift_project_criterion`) and
1175    // infallible here, so it needs no `?`.
1176    let (query, project) = lift_project_criterion(&store.config, &query);
1177    // `epic:` is the second lifted membership criterion, exactly as `project:` is
1178    // (WP06 T029): the reference list is a YAML block, so it needs the same
1179    // best-effort service resolution and infallible lift.
1180    let (query, epic) = lift_epic_criterion(&store.config, &query);
1181    Ok((
1182        query,
1183        StructuredFilter {
1184            category,
1185            project,
1186            epic,
1187            scope,
1188            archived,
1189            ..StructuredFilter::default()
1190        },
1191    ))
1192}
1193
1194/// Lift an `archived:true|false` token out of the query into the
1195/// [`StructuredFilter::archived`] field (issue #1492).
1196///
1197/// The archived state is derived from the filename, so it cannot be judged from
1198/// the serialized bytes and the token has to arrive as a structured value — the
1199/// same shape as `scope:` and `category:`. The last token wins, as both of those
1200/// do. A value that is neither `true` nor `false` is refused by name, because
1201/// `archived:1` matching nothing would be indistinguishable from a correct filter
1202/// with no matches.
1203pub(super) fn lift_archived_criterion(query: &str) -> crate::error::Result<(String, Option<bool>)> {
1204    let mut out: Vec<String> = Vec::with_capacity(8);
1205    let mut archived = None;
1206    for token in query.split_whitespace() {
1207        let Some((key, value)) = parse_token(token) else {
1208            out.push(token.to_string());
1209            continue;
1210        };
1211        if !key.eq_ignore_ascii_case("archived") {
1212            out.push(token.to_string());
1213            continue;
1214        }
1215        archived = match value.to_ascii_lowercase().as_str() {
1216            "true" => Some(true),
1217            "false" => Some(false),
1218            other => {
1219                return Err(crate::error::TixError::InvalidInput(format!(
1220                    "invalid archived value '{other}' — use archived:true or archived:false"
1221                )));
1222            }
1223        };
1224        // Dropped, not pushed: `out` is the query the store-free matcher will see.
1225    }
1226    Ok((out.join(" "), archived))
1227}
1228
1229/// Parse a structured query token of the form `key:value`.
1230pub(super) fn parse_token(token: &str) -> Option<(&str, &str)> {
1231    let (key, value) = token.split_once(':')?;
1232    let value = value.trim();
1233    if key.is_empty() || value.is_empty() {
1234        return None;
1235    }
1236    Some((key, value))
1237}
1238
1239pub fn control_token(query: &str, key: &str) -> Option<String> {
1240    query.split_whitespace().find_map(|token| {
1241        let (k, v) = token.split_once(':')?;
1242        if k.eq_ignore_ascii_case(key) {
1243            Some(v.to_ascii_lowercase())
1244        } else {
1245            None
1246        }
1247    })
1248}