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}