Skip to main content

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}