tix/roadmap/filter.rs
1//! The one normalized roadmap filter (WP02, FR-004, C-007, NFR-003).
2//!
3//! Every surface that narrows a roadmap — the CLI flags, the TUI controls, the MCP
4//! tool arguments, the local portable DTO — parses into [`RoadmapFilter`] and
5//! nothing else, so no surface can own a second filter grammar or a second
6//! matching rule (decision `filter_model`, C-007). A surface is responsible only
7//! for *parsing what a user typed*; the predicate below is the single place a
8//! criterion is applied.
9//!
10//! # Syntax is refused; non-matching is not
11//!
12//! The two halves are deliberately asymmetric:
13//!
14//! * a value that is **malformed** — a date that is not `YYYY-MM-DD`, a
15//! `period_from` after its `period_to`, a blank owner/status/label — is a named
16//! [`TixError::Config`] refusal from [`RoadmapFilter::validate`], raised *before*
17//! any initiative is projected;
18//! * a value that is **well formed but names nothing** — a status no initiative
19//! carries, an owner nobody has, a label nothing uses — is **not** an error. It
20//! simply matches no record, and the projection is an empty ordered set. This is
21//! the reading #178/#179 settled for an unknown reference in a filter (only the
22//! syntax is checked; an unknown reference matches nothing), and it is what makes
23//! a filter usable before the records it names exist.
24//!
25//! An *empty* filter ([`RoadmapFilter::is_empty`]) is not "matches nothing": it is
26//! the documented "no filter" value — the default — and matches every initiative.
27//!
28//! # Matching semantics
29//!
30//! * `period_from` / `period_to` are **inclusive** calendar bounds compared against
31//! the initiative's `target_date`. A period bound requires a target date: an
32//! undated initiative cannot be shown to lie inside a calendar period and is
33//! never invented into one (C-003), so a bounded filter excludes it. The row is
34//! still present in the *unfiltered* projection — see [`crate::roadmap::order`],
35//! where a missing target date is an explicit ordering state, not a hidden row.
36//! * `owner` and `status` are exact **normalized** matches: the value and the
37//! record's own field are both trimmed and case-folded, which is the comparison
38//! the sibling name lookups in this tree already use.
39//! * `labels` is an **AND**: an initiative matches only when it carries *every*
40//! requested label. Each label is compared trimmed and case-insensitively (the
41//! repository lowercases labels elsewhere), so a surface may pass through what a
42//! user typed without pre-normalizing it.
43//! * `query` is free text matched, case-insensitively, as a substring of **any one**
44//! of the initiative's searchable fields: `name`, `outcome`, `owner`, `status`,
45//! `labels`. Fields are searched independently, so a query never matches across a
46//! field boundary. A blank/whitespace-only query is **not** a criterion (it
47//! narrows nothing) rather than a refusal — free text has no "unknown value" form.
48//!
49//! The substring matcher lives here, in one documented place, rather than reusing
50//! [`crate::search`]: that module's reusable entry points ([`crate::search::search_substring`],
51//! [`crate::search::matches`]) are defined over an issue [`Corpus`](crate::search::Corpus)
52//! and the structured issue filter grammar, neither of which describes an
53//! initiative record. Reusing them would mean building a throwaway corpus per
54//! query; the per-field fold-and-contain below is the honest implementation.
55
56use std::borrow::Cow;
57
58use serde::{Deserialize, Serialize};
59
60use crate::config::InitiativeConfiguration;
61use crate::error::{Result, TixError};
62use crate::model::{date_lte, validate_date};
63
64/// The normalized cross-surface roadmap filter (data-model §3).
65///
66/// All criteria are optional, so the default value is the empty filter; an empty
67/// filter narrows nothing and selects every initiative. The field names are the
68/// serialized names (see the `serde` attributes) and are **stable**: they are what
69/// the MCP tool arguments and the local portable DTO carry, so a renamed field
70/// would be a breaking change to every surface at once.
71///
72/// Unknown serialized fields are **refused** (`deny_unknown_fields`), which is
73/// deliberate: with a permissive reader a typo'd key (`"ownerr"`) would silently
74/// drop the criterion and return *more* rows than asked for. A filter is a strict
75/// input; the forward-compatibility budget this mission spends is on the preserved
76/// initiative `extensions`, not on a query language.
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(default, deny_unknown_fields)]
79pub struct RoadmapFilter {
80 /// Inclusive lower bound on the initiative target date (`YYYY-MM-DD`).
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub period_from: Option<String>,
83 /// Inclusive upper bound on the initiative target date (`YYYY-MM-DD`).
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub period_to: Option<String>,
86 /// Owner identity to match; compared trimmed and case-insensitively.
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub owner: Option<String>,
89 /// Initiative status to match; compared trimmed and case-insensitively.
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub status: Option<String>,
92 /// Labels the initiative must **all** carry; each compared trimmed and
93 /// case-insensitively, order irrelevant.
94 #[serde(skip_serializing_if = "Vec::is_empty")]
95 pub labels: Vec<String>,
96 /// Free text matched case-insensitively against name/outcome/owner/status/labels.
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub query: Option<String>,
99}
100
101impl RoadmapFilter {
102 /// Whether this filter carries no criterion at all — the documented "no
103 /// filter" value.
104 ///
105 /// `RoadmapFilter::default()` is the empty filter, so a call site that supports
106 /// "show everything" has one value to pass rather than four `None`s it must
107 /// keep in step. A blank `query` counts as absent because it narrows nothing;
108 /// blank `owner`/`status`/`labels` are refusals and so are assumed absent here
109 /// once [`Self::validate`] has run.
110 #[must_use]
111 pub fn is_empty(&self) -> bool {
112 self.period_from.is_none()
113 && self.period_to.is_none()
114 && self.owner.is_none()
115 && self.status.is_none()
116 && self.labels.is_empty()
117 && self
118 .query
119 .as_deref()
120 .is_none_or(|query| query.trim().is_empty())
121 }
122
123 /// Validate the filter's **syntax** before any projection runs.
124 ///
125 /// Refuses: a `period_from`/`period_to` that is not a `YYYY-MM-DD` calendar date
126 /// (the shared [`crate::model::validate_date`] rule, the same one the record's
127 /// own loader uses), a period whose lower bound is after its upper bound, and a
128 /// present-but-blank `owner`, `status`, or `labels` entry. A well-formed value
129 /// that names no initiative is **not** refused — it matches nothing (see the
130 /// module docs).
131 pub fn validate(&self) -> Result<()> {
132 if let Some(from) = self.period_from.as_deref() {
133 if !validate_date(from) {
134 return Err(TixError::Config(format!(
135 "roadmap filter `period_from` (`{from}`) must be a `YYYY-MM-DD` calendar date"
136 )));
137 }
138 }
139 if let Some(to) = self.period_to.as_deref() {
140 if !validate_date(to) {
141 return Err(TixError::Config(format!(
142 "roadmap filter `period_to` (`{to}`) must be a `YYYY-MM-DD` calendar date"
143 )));
144 }
145 }
146 if let (Some(from), Some(to)) = (self.period_from.as_deref(), self.period_to.as_deref())
147 && !date_lte(from, to)
148 {
149 return Err(TixError::Config(format!(
150 "roadmap filter `period_from` (`{from}`) must be <= `period_to` (`{to}`)"
151 )));
152 }
153 for (field, value) in [
154 ("owner", self.owner.as_deref()),
155 ("status", self.status.as_deref()),
156 ] {
157 if let Some(value) = value
158 && value.trim().is_empty()
159 {
160 return Err(TixError::Config(format!(
161 "roadmap filter `{field}` must not be blank"
162 )));
163 }
164 }
165 for label in &self.labels {
166 if label.trim().is_empty() {
167 return Err(TixError::Config(
168 "roadmap filter `labels` must not contain a blank value".into(),
169 ));
170 }
171 }
172 Ok(())
173 }
174
175 /// Whether one initiative record satisfies every criterion this filter sets.
176 ///
177 /// Purely read-only: it inspects the record and mutates nothing. An empty filter
178 /// matches every record.
179 #[must_use]
180 pub fn matches(&self, initiative: &InitiativeConfiguration) -> bool {
181 self.matches_period(initiative)
182 && self.matches_owner(initiative)
183 && self.matches_status(initiative)
184 && self.matches_labels(initiative)
185 && self.matches_query(initiative)
186 }
187
188 /// The inclusive calendar-period predicate.
189 ///
190 /// A bound requires a target date: a record without one is excluded by any
191 /// bounded filter, because a missing date is not invented into a period
192 /// (C-003). No bound set → everything passes.
193 fn matches_period(&self, initiative: &InitiativeConfiguration) -> bool {
194 if self.period_from.is_none() && self.period_to.is_none() {
195 return true;
196 }
197 let Some(date) = initiative.target_date.as_deref() else {
198 return false;
199 };
200 if let Some(from) = self.period_from.as_deref()
201 && !date_lte(from, date)
202 {
203 return false;
204 }
205 if let Some(to) = self.period_to.as_deref()
206 && !date_lte(date, to)
207 {
208 return false;
209 }
210 true
211 }
212
213 /// The exact normalized owner predicate.
214 fn matches_owner(&self, initiative: &InitiativeConfiguration) -> bool {
215 match self.owner.as_deref() {
216 None => true,
217 Some(wanted) => match initiative.owner.as_deref() {
218 None => false,
219 Some(actual) => normalized(actual) == normalized(wanted),
220 },
221 }
222 }
223
224 /// The exact normalized status predicate.
225 fn matches_status(&self, initiative: &InitiativeConfiguration) -> bool {
226 match self.status.as_deref() {
227 None => true,
228 Some(wanted) => match initiative.status.as_deref() {
229 None => false,
230 Some(actual) => normalized(actual) == normalized(wanted),
231 },
232 }
233 }
234
235 /// The AND-of-all-labels predicate (each compared trimmed, case-insensitively).
236 fn matches_labels(&self, initiative: &InitiativeConfiguration) -> bool {
237 self.labels.iter().all(|wanted| {
238 let wanted = normalized(wanted);
239 initiative
240 .labels
241 .iter()
242 .any(|actual| normalized(actual) == wanted)
243 })
244 }
245
246 /// The substring query predicate over the searchable fields.
247 ///
248 /// Each field is searched **independently**, so a query cannot match across a
249 /// field boundary: no separator is inserted, and none is matchable. A query
250 /// containing a newline (`"Alpha\nalice"`) therefore matches only when one field
251 /// really contains it, not when the newline happens to fall between a name's end
252 /// and an owner's start.
253 fn matches_query(&self, initiative: &InitiativeConfiguration) -> bool {
254 let Some(query) = self.query.as_deref() else {
255 return true;
256 };
257 let needle = normalized(query);
258 if needle.is_empty() {
259 return true;
260 }
261 std::iter::once(initiative.name.as_str())
262 .chain(initiative.outcome.as_deref())
263 .chain(initiative.owner.as_deref())
264 .chain(initiative.status.as_deref())
265 .chain(initiative.labels.iter().map(String::as_str))
266 .any(|field| field.to_lowercase().contains(needle.as_ref()))
267 }
268}
269
270/// Trim and case-fold a criterion value: the "normalized match" of data-model §3.
271///
272/// The fold is decided by comparing against the folded form, **not** by probing for
273/// an uppercase character: the Unicode derived *Uppercase* property excludes
274/// titlecase glyphs (category `Lt`), which `to_lowercase` nevertheless changes, so a
275/// probe would leave `\u{01C5}` un-folded and silently break case-insensitivity for
276/// those and for multi-character expansions such as `\u{0130}` → `"i\u{0307}"`. The
277/// borrow is kept when folding changes nothing, so the common already-folded value
278/// allocates nothing.
279fn normalized(value: &str) -> Cow<'_, str> {
280 let trimmed = value.trim();
281 let lowered = trimmed.to_lowercase();
282 if lowered == trimmed {
283 Cow::Borrowed(trimmed)
284 } else {
285 Cow::Owned(lowered)
286 }
287}