#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterField {
Assignee,
Label,
Status,
Priority,
Cycle,
Milestone,
Reviewer,
StartDate,
DueDate,
Parent,
Category,
Project,
Epic,
}
impl FilterField {
pub const ALL: [FilterField; 13] = [
FilterField::Assignee,
FilterField::Label,
FilterField::Status,
FilterField::Priority,
FilterField::Cycle,
FilterField::Milestone,
FilterField::Reviewer,
FilterField::StartDate,
FilterField::DueDate,
FilterField::Parent,
FilterField::Category,
FilterField::Project,
FilterField::Epic,
];
pub fn label(self) -> &'static str {
match self {
FilterField::Assignee => "Assignee",
FilterField::Label => "Label",
FilterField::Status => "Status",
FilterField::Priority => "Priority",
FilterField::Cycle => "Cycle",
FilterField::Milestone => "Milestone",
FilterField::Reviewer => "Reviewer",
FilterField::StartDate => "Start date",
FilterField::DueDate => "Due date",
FilterField::Parent => "Parent",
FilterField::Category => "Category",
FilterField::Project => "Project",
FilterField::Epic => "Epic",
}
}
pub fn key(self) -> &'static str {
match self {
FilterField::Assignee => "assignee",
FilterField::Label => "labels",
FilterField::Status => "status",
FilterField::Priority => "priority",
FilterField::Cycle => "cycle",
FilterField::Milestone => "milestone",
FilterField::Reviewer => "reviewer",
FilterField::StartDate => "start_date",
FilterField::DueDate => "due_date",
FilterField::Parent => "parent",
FilterField::Category => "category",
FilterField::Project => "projects",
FilterField::Epic => crate::epics::EPICS_KEY,
}
}
pub fn is_derived(self) -> bool {
matches!(self, FilterField::Category)
}
pub fn describe(self) -> String {
if self.is_derived() {
format!("{} (derived from status)", self.key())
} else {
self.key().to_string()
}
}
pub fn row(self) -> usize {
FilterField::ALL
.iter()
.position(|field| *field == self)
.expect("FilterField variants are all in ALL")
}
pub fn next(self) -> Self {
let all = FilterField::ALL;
all[(self.row() + 1) % all.len()]
}
pub fn prev(self) -> Self {
let all = FilterField::ALL;
all[(self.row() + all.len() - 1) % all.len()]
}
pub fn value(self, filter: &StructuredFilter) -> Option<&str> {
let value = match self {
FilterField::Assignee => filter.assignee.as_deref(),
FilterField::Label => filter.label.as_deref(),
FilterField::Status => filter.status.as_deref(),
FilterField::Priority => filter.priority.as_deref(),
FilterField::Cycle => filter.cycle.as_deref(),
FilterField::Milestone => filter.milestone.as_deref(),
FilterField::Reviewer => filter.reviewer.as_deref(),
FilterField::StartDate => filter.start_date.as_deref(),
FilterField::DueDate => filter.due_date.as_deref(),
FilterField::Parent => filter.parent.as_deref(),
FilterField::Category => filter.category.as_deref(),
FilterField::Project => filter.project.as_deref(),
FilterField::Epic => filter.epic.as_deref(),
};
value.filter(|s| !s.is_empty())
}
pub fn value_mut(self, filter: &mut StructuredFilter) -> &mut String {
match self {
FilterField::Assignee => filter.assignee.get_or_insert_default(),
FilterField::Label => filter.label.get_or_insert_default(),
FilterField::Status => filter.status.get_or_insert_default(),
FilterField::Priority => filter.priority.get_or_insert_default(),
FilterField::Cycle => filter.cycle.get_or_insert_default(),
FilterField::Milestone => filter.milestone.get_or_insert_default(),
FilterField::Reviewer => filter.reviewer.get_or_insert_default(),
FilterField::StartDate => filter.start_date.get_or_insert_default(),
FilterField::DueDate => filter.due_date.get_or_insert_default(),
FilterField::Parent => filter.parent.get_or_insert_default(),
FilterField::Category => filter.category.get_or_insert_default(),
FilterField::Project => filter.project.get_or_insert_default(),
FilterField::Epic => filter.epic.get_or_insert_default(),
}
}
pub fn unset(self, filter: &mut StructuredFilter) {
match self {
FilterField::Assignee => filter.assignee = None,
FilterField::Label => filter.label = None,
FilterField::Status => filter.status = None,
FilterField::Priority => filter.priority = None,
FilterField::Cycle => filter.cycle = None,
FilterField::Milestone => filter.milestone = None,
FilterField::Reviewer => filter.reviewer = None,
FilterField::StartDate => filter.start_date = None,
FilterField::DueDate => filter.due_date = None,
FilterField::Parent => filter.parent = None,
FilterField::Category => filter.category = None,
FilterField::Project => filter.project = None,
FilterField::Epic => filter.epic = None,
}
}
pub fn is_reference(self) -> bool {
matches!(self, FilterField::Parent)
}
pub fn token(self) -> &'static str {
match self {
FilterField::Project => "project",
FilterField::Epic => "epic",
_ => self.key(),
}
}
pub fn is_project(self) -> bool {
matches!(self, FilterField::Project)
}
pub fn is_epic(self) -> bool {
matches!(self, FilterField::Epic)
}
pub fn is_date(self) -> bool {
matches!(self, FilterField::StartDate | FilterField::DueDate)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StructuredFilter {
pub assignee: Option<String>,
pub label: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub cycle: Option<String>,
pub milestone: Option<String>,
pub reviewer: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub overdue: Option<bool>,
pub parent: Option<String>,
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
}
impl StructuredFilter {
pub fn criteria(&self) -> impl Iterator<Item = (FilterField, &str)> {
FilterField::ALL
.into_iter()
.filter_map(move |field| field.value(self).map(|value| (field, value)))
}
pub fn has_active_criteria(&self) -> bool {
self.criteria().next().is_some() || self.scope.is_some() || self.archived.is_some()
}
pub fn evaluate(&self, haystack: &str) -> bool {
let hay = haystack.to_lowercase();
if let Some(number) = &self.parent
&& field_value(haystack, "parent") != Some(number.as_str())
{
return false;
}
for (field, value) in self.criteria() {
if field.is_reference() || field.is_derived() || field.is_project() || field.is_epic() {
continue;
}
if !hay.contains(&value.to_lowercase()) {
return false;
}
}
if let Some(cycle) = &self.cycle
&& !hay.contains(&cycle.to_lowercase())
{
return false;
}
if let Some(milestone) = &self.milestone
&& !hay.contains(&milestone.to_lowercase())
{
return false;
}
if let Some(reviewer) = &self.reviewer
&& !hay.contains(&reviewer.to_lowercase())
{
return false;
}
if let Some(start_date) = &self.start_date
&& !hay.contains(start_date)
{
return false;
}
if let Some(due_date) = &self.due_date
&& !hay.contains(due_date)
{
return false;
}
if self.overdue == Some(true) && !is_overdue_haystack(haystack) {
return false;
}
true
}
pub fn evaluate_issue(
&self,
issue: &Issue,
text: &str,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
self.evaluate(text)
&& self.category_matches(issue, resolution)
&& self.scope_matches(issue)
&& self.archived_matches(issue)
&& self.project_matches(issue)
&& self.epic_matches(issue)
}
pub fn project_matches(&self, issue: &Issue) -> bool {
let Some(want) = self.project.as_deref().filter(|s| !s.is_empty()) else {
return true;
};
crate::projects::ProjectNode::project_ids(issue)
.iter()
.any(|id| id == want)
}
pub fn epic_matches(&self, issue: &Issue) -> bool {
let Some(want) = self.epic.as_deref().filter(|s| !s.is_empty()) else {
return true;
};
crate::epics::EpicNode::epic_ids(issue)
.iter()
.any(|id| id == want)
}
pub fn archived_matches(&self, issue: &Issue) -> bool {
match self.archived {
None => true,
Some(want) => issue.archived == want,
}
}
pub fn scope_matches(&self, issue: &Issue) -> bool {
match &self.scope {
None => true,
Some(columns) => columns.iter().any(|c| issue.status.is(c)),
}
}
pub fn category_matches(
&self,
issue: &Issue,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
let Some(want) = self.category.as_deref().filter(|s| !s.is_empty()) else {
return true;
};
match resolution.resolve(issue.status.display()) {
Some(actual) => actual.key() == want,
None => false,
}
}
pub fn normalize(&mut self) {
for field in FilterField::ALL {
let empty = field.value_mut(self).trim().is_empty();
if empty {
field.unset(self);
} else {
let value = field.value_mut(self);
let trimmed = value.trim().to_string();
*value = trimmed;
}
}
}
}
impl std::fmt::Display for StructuredFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts: Vec<String> = self
.criteria()
.map(|(field, value)| format!("{}:{value}", field.token()))
.collect();
if let Some(columns) = &self.scope {
parts.push(format!("scope:{}", columns.join(",")));
}
if let Some(archived) = self.archived {
parts.push(format!("archived:{archived}"));
}
if self.overdue == Some(true) {
parts.push("overdue:true".to_string());
}
if parts.is_empty() {
return write!(f, "(no active criteria)");
}
write!(f, "{}", parts.join(", "))
}
}
pub fn matches(query: &str, filter: &StructuredFilter, haystack: &str) -> bool {
let text_matches = query_matches(query, haystack);
text_matches && filter.evaluate(haystack)
}
pub fn matches_issue(
query: &str,
filter: &StructuredFilter,
issue: &Issue,
text: &str,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
query_matches(query, text) && filter.evaluate_issue(issue, text, resolution)
}
pub fn resolve_parent_criterion(store: &crate::storage::Store, query: &str) -> Result<String> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut touched = false;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("parent") {
out.push(token.to_string());
continue;
}
let target = crate::commands::parse_selector(store, value)?;
let issue = store.get(target).map_err(|_| {
crate::error::TixError::NotFound(format!(
"issue matching '{value}' by number, filename or title"
))
})?;
let canonical = crate::id::canonical_ref(&issue);
let value = canonical.as_number().map_or_else(
|| canonical.to_string(),
|number| format!("{canonical}|{number}"),
);
out.push(format!("parent:{value}"));
touched = true;
}
if !touched {
return Ok(query.to_string());
}
Ok(out.join(" "))
}
pub(super) fn lift_category_criterion(
config: &crate::config::Config,
query: &str,
) -> crate::error::Result<(String, Option<String>)> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut category = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("category") {
out.push(token.to_string());
continue;
}
let lowered = value.to_lowercase();
if !config.categories.contains(&lowered) {
return Err(crate::error::TixError::InvalidInput(format!(
"unknown category '{value}' — this project's categories are: {}",
config.categories.order.join(", ")
)));
}
category = Some(
crate::workflow::WorkflowCategory::from_key(&lowered)
.expect("categories.order is validated against the global vocabulary at load")
.key()
.to_string(),
);
}
Ok((out.join(" "), category))
}
pub(super) fn lift_project_criterion(
config: &crate::config::Config,
query: &str,
) -> (String, Option<String>) {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut project = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("project") {
out.push(token.to_string());
continue;
}
project = Some(
crate::projects::resolve_project_id(config, value)
.map(|id| id.to_string())
.unwrap_or_else(|_| value.to_string()),
);
}
(out.join(" "), project)
}
pub(super) fn lift_epic_criterion(
config: &crate::config::Config,
query: &str,
) -> (String, Option<String>) {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut epic = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("epic") {
out.push(token.to_string());
continue;
}
epic = Some(crate::epics::dereference_needle(&config.epics, value));
}
(out.join(" "), epic)
}
pub fn epic_bucket_label(ids: &[String]) -> String {
match ids {
[] => NO_EPIC_BUCKET.to_string(),
[only] => only.clone(),
_ => MULTIPLE_EPICS_BUCKET.to_string(),
}
}
pub const NO_EPIC_BUCKET: &str = "(no epic)";
pub const MULTIPLE_EPICS_BUCKET: &str = "(multiple epics)";
pub(super) fn lift_scope_criterion(
config: &crate::config::Config,
query: &str,
) -> crate::error::Result<(String, Option<Vec<String>>)> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut scope = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("scope") {
out.push(token.to_string());
continue;
}
scope = Some(crate::triage::inbox::scope_columns(config, value)?);
}
Ok((out.join(" "), scope))
}
pub fn resolve_filter_criteria(
store: &crate::storage::Store,
query: &str,
) -> Result<(String, StructuredFilter)> {
let query = resolve_parent_criterion(store, query)?;
let (query, category) = lift_category_criterion(&store.config, &query)?;
let (query, scope) = lift_scope_criterion(&store.config, &query)?;
let (query, archived) = lift_archived_criterion(&query)?;
let (query, project) = lift_project_criterion(&store.config, &query);
let (query, epic) = lift_epic_criterion(&store.config, &query);
Ok((
query,
StructuredFilter {
category,
project,
epic,
scope,
archived,
..StructuredFilter::default()
},
))
}
pub(super) fn lift_archived_criterion(query: &str) -> crate::error::Result<(String, Option<bool>)> {
let mut out: Vec<String> = Vec::with_capacity(8);
let mut archived = None;
for token in query.split_whitespace() {
let Some((key, value)) = parse_token(token) else {
out.push(token.to_string());
continue;
};
if !key.eq_ignore_ascii_case("archived") {
out.push(token.to_string());
continue;
}
archived = match value.to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
other => {
return Err(crate::error::TixError::InvalidInput(format!(
"invalid archived value '{other}' — use archived:true or archived:false"
)));
}
};
}
Ok((out.join(" "), archived))
}
pub(super) fn parse_token(token: &str) -> Option<(&str, &str)> {
let (key, value) = token.split_once(':')?;
let value = value.trim();
if key.is_empty() || value.is_empty() {
return None;
}
Some((key, value))
}
pub fn control_token(query: &str, key: &str) -> Option<String> {
query.split_whitespace().find_map(|token| {
let (k, v) = token.split_once(':')?;
if k.eq_ignore_ascii_case(key) {
Some(v.to_ascii_lowercase())
} else {
None
}
})
}