tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The one normalized roadmap filter (WP02, FR-004, C-007, NFR-003).
//!
//! Every surface that narrows a roadmap — the CLI flags, the TUI controls, the MCP
//! tool arguments, the local portable DTO — parses into [`RoadmapFilter`] and
//! nothing else, so no surface can own a second filter grammar or a second
//! matching rule (decision `filter_model`, C-007). A surface is responsible only
//! for *parsing what a user typed*; the predicate below is the single place a
//! criterion is applied.
//!
//! # Syntax is refused; non-matching is not
//!
//! The two halves are deliberately asymmetric:
//!
//! * a value that is **malformed** — a date that is not `YYYY-MM-DD`, a
//!   `period_from` after its `period_to`, a blank owner/status/label — is a named
//!   [`TixError::Config`] refusal from [`RoadmapFilter::validate`], raised *before*
//!   any initiative is projected;
//! * a value that is **well formed but names nothing** — a status no initiative
//!   carries, an owner nobody has, a label nothing uses — is **not** an error. It
//!   simply matches no record, and the projection is an empty ordered set. This is
//!   the reading #178/#179 settled for an unknown reference in a filter (only the
//!   syntax is checked; an unknown reference matches nothing), and it is what makes
//!   a filter usable before the records it names exist.
//!
//! An *empty* filter ([`RoadmapFilter::is_empty`]) is not "matches nothing": it is
//! the documented "no filter" value — the default — and matches every initiative.
//!
//! # Matching semantics
//!
//! * `period_from` / `period_to` are **inclusive** calendar bounds compared against
//!   the initiative's `target_date`. A period bound requires a target date: an
//!   undated initiative cannot be shown to lie inside a calendar period and is
//!   never invented into one (C-003), so a bounded filter excludes it. The row is
//!   still present in the *unfiltered* projection — see [`crate::roadmap::order`],
//!   where a missing target date is an explicit ordering state, not a hidden row.
//! * `owner` and `status` are exact **normalized** matches: the value and the
//!   record's own field are both trimmed and case-folded, which is the comparison
//!   the sibling name lookups in this tree already use.
//! * `labels` is an **AND**: an initiative matches only when it carries *every*
//!   requested label. Each label is compared trimmed and case-insensitively (the
//!   repository lowercases labels elsewhere), so a surface may pass through what a
//!   user typed without pre-normalizing it.
//! * `query` is free text matched, case-insensitively, as a substring of **any one**
//!   of the initiative's searchable fields: `name`, `outcome`, `owner`, `status`,
//!   `labels`. Fields are searched independently, so a query never matches across a
//!   field boundary. A blank/whitespace-only query is **not** a criterion (it
//!   narrows nothing) rather than a refusal — free text has no "unknown value" form.
//!
//! The substring matcher lives here, in one documented place, rather than reusing
//! [`crate::search`]: that module's reusable entry points ([`crate::search::search_substring`],
//! [`crate::search::matches`]) are defined over an issue [`Corpus`](crate::search::Corpus)
//! and the structured issue filter grammar, neither of which describes an
//! initiative record. Reusing them would mean building a throwaway corpus per
//! query; the per-field fold-and-contain below is the honest implementation.

use std::borrow::Cow;

use serde::{Deserialize, Serialize};

use crate::config::InitiativeConfiguration;
use crate::error::{Result, TixError};
use crate::model::{date_lte, validate_date};

/// The normalized cross-surface roadmap filter (data-model §3).
///
/// All criteria are optional, so the default value is the empty filter; an empty
/// filter narrows nothing and selects every initiative. The field names are the
/// serialized names (see the `serde` attributes) and are **stable**: they are what
/// the MCP tool arguments and the local portable DTO carry, so a renamed field
/// would be a breaking change to every surface at once.
///
/// Unknown serialized fields are **refused** (`deny_unknown_fields`), which is
/// deliberate: with a permissive reader a typo'd key (`"ownerr"`) would silently
/// drop the criterion and return *more* rows than asked for. A filter is a strict
/// input; the forward-compatibility budget this mission spends is on the preserved
/// initiative `extensions`, not on a query language.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RoadmapFilter {
    /// Inclusive lower bound on the initiative target date (`YYYY-MM-DD`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub period_from: Option<String>,
    /// Inclusive upper bound on the initiative target date (`YYYY-MM-DD`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub period_to: Option<String>,
    /// Owner identity to match; compared trimmed and case-insensitively.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    /// Initiative status to match; compared trimmed and case-insensitively.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// Labels the initiative must **all** carry; each compared trimmed and
    /// case-insensitively, order irrelevant.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub labels: Vec<String>,
    /// Free text matched case-insensitively against name/outcome/owner/status/labels.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub query: Option<String>,
}

impl RoadmapFilter {
    /// Whether this filter carries no criterion at all — the documented "no
    /// filter" value.
    ///
    /// `RoadmapFilter::default()` is the empty filter, so a call site that supports
    /// "show everything" has one value to pass rather than four `None`s it must
    /// keep in step. A blank `query` counts as absent because it narrows nothing;
    /// blank `owner`/`status`/`labels` are refusals and so are assumed absent here
    /// once [`Self::validate`] has run.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.period_from.is_none()
            && self.period_to.is_none()
            && self.owner.is_none()
            && self.status.is_none()
            && self.labels.is_empty()
            && self
                .query
                .as_deref()
                .is_none_or(|query| query.trim().is_empty())
    }

    /// Validate the filter's **syntax** before any projection runs.
    ///
    /// Refuses: a `period_from`/`period_to` that is not a `YYYY-MM-DD` calendar date
    /// (the shared [`crate::model::validate_date`] rule, the same one the record's
    /// own loader uses), a period whose lower bound is after its upper bound, and a
    /// present-but-blank `owner`, `status`, or `labels` entry. A well-formed value
    /// that names no initiative is **not** refused — it matches nothing (see the
    /// module docs).
    pub fn validate(&self) -> Result<()> {
        if let Some(from) = self.period_from.as_deref() {
            if !validate_date(from) {
                return Err(TixError::Config(format!(
                    "roadmap filter `period_from` (`{from}`) must be a `YYYY-MM-DD` calendar date"
                )));
            }
        }
        if let Some(to) = self.period_to.as_deref() {
            if !validate_date(to) {
                return Err(TixError::Config(format!(
                    "roadmap filter `period_to` (`{to}`) must be a `YYYY-MM-DD` calendar date"
                )));
            }
        }
        if let (Some(from), Some(to)) = (self.period_from.as_deref(), self.period_to.as_deref())
            && !date_lte(from, to)
        {
            return Err(TixError::Config(format!(
                "roadmap filter `period_from` (`{from}`) must be <= `period_to` (`{to}`)"
            )));
        }
        for (field, value) in [
            ("owner", self.owner.as_deref()),
            ("status", self.status.as_deref()),
        ] {
            if let Some(value) = value
                && value.trim().is_empty()
            {
                return Err(TixError::Config(format!(
                    "roadmap filter `{field}` must not be blank"
                )));
            }
        }
        for label in &self.labels {
            if label.trim().is_empty() {
                return Err(TixError::Config(
                    "roadmap filter `labels` must not contain a blank value".into(),
                ));
            }
        }
        Ok(())
    }

    /// Whether one initiative record satisfies every criterion this filter sets.
    ///
    /// Purely read-only: it inspects the record and mutates nothing. An empty filter
    /// matches every record.
    #[must_use]
    pub fn matches(&self, initiative: &InitiativeConfiguration) -> bool {
        self.matches_period(initiative)
            && self.matches_owner(initiative)
            && self.matches_status(initiative)
            && self.matches_labels(initiative)
            && self.matches_query(initiative)
    }

    /// The inclusive calendar-period predicate.
    ///
    /// A bound requires a target date: a record without one is excluded by any
    /// bounded filter, because a missing date is not invented into a period
    /// (C-003). No bound set → everything passes.
    fn matches_period(&self, initiative: &InitiativeConfiguration) -> bool {
        if self.period_from.is_none() && self.period_to.is_none() {
            return true;
        }
        let Some(date) = initiative.target_date.as_deref() else {
            return false;
        };
        if let Some(from) = self.period_from.as_deref()
            && !date_lte(from, date)
        {
            return false;
        }
        if let Some(to) = self.period_to.as_deref()
            && !date_lte(date, to)
        {
            return false;
        }
        true
    }

    /// The exact normalized owner predicate.
    fn matches_owner(&self, initiative: &InitiativeConfiguration) -> bool {
        match self.owner.as_deref() {
            None => true,
            Some(wanted) => match initiative.owner.as_deref() {
                None => false,
                Some(actual) => normalized(actual) == normalized(wanted),
            },
        }
    }

    /// The exact normalized status predicate.
    fn matches_status(&self, initiative: &InitiativeConfiguration) -> bool {
        match self.status.as_deref() {
            None => true,
            Some(wanted) => match initiative.status.as_deref() {
                None => false,
                Some(actual) => normalized(actual) == normalized(wanted),
            },
        }
    }

    /// The AND-of-all-labels predicate (each compared trimmed, case-insensitively).
    fn matches_labels(&self, initiative: &InitiativeConfiguration) -> bool {
        self.labels.iter().all(|wanted| {
            let wanted = normalized(wanted);
            initiative
                .labels
                .iter()
                .any(|actual| normalized(actual) == wanted)
        })
    }

    /// The substring query predicate over the searchable fields.
    ///
    /// Each field is searched **independently**, so a query cannot match across a
    /// field boundary: no separator is inserted, and none is matchable. A query
    /// containing a newline (`"Alpha\nalice"`) therefore matches only when one field
    /// really contains it, not when the newline happens to fall between a name's end
    /// and an owner's start.
    fn matches_query(&self, initiative: &InitiativeConfiguration) -> bool {
        let Some(query) = self.query.as_deref() else {
            return true;
        };
        let needle = normalized(query);
        if needle.is_empty() {
            return true;
        }
        std::iter::once(initiative.name.as_str())
            .chain(initiative.outcome.as_deref())
            .chain(initiative.owner.as_deref())
            .chain(initiative.status.as_deref())
            .chain(initiative.labels.iter().map(String::as_str))
            .any(|field| field.to_lowercase().contains(needle.as_ref()))
    }
}

/// Trim and case-fold a criterion value: the "normalized match" of data-model §3.
///
/// The fold is decided by comparing against the folded form, **not** by probing for
/// an uppercase character: the Unicode derived *Uppercase* property excludes
/// titlecase glyphs (category `Lt`), which `to_lowercase` nevertheless changes, so a
/// probe would leave `\u{01C5}` un-folded and silently break case-insensitivity for
/// those and for multi-character expansions such as `\u{0130}` → `"i\u{0307}"`. The
/// borrow is kept when folding changes nothing, so the common already-folded value
/// allocates nothing.
fn normalized(value: &str) -> Cow<'_, str> {
    let trimmed = value.trim();
    let lowered = trimmed.to_lowercase();
    if lowered == trimmed {
        Cow::Borrowed(trimmed)
    } else {
        Cow::Owned(lowered)
    }
}