use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use crate::config::InitiativeConfiguration;
use crate::error::{Result, TixError};
use crate::model::{date_lte, validate_date};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RoadmapFilter {
#[serde(skip_serializing_if = "Option::is_none")]
pub period_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub period_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
}
impl RoadmapFilter {
#[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())
}
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(())
}
#[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)
}
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
}
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),
},
}
}
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),
},
}
}
fn matches_labels(&self, initiative: &InitiativeConfiguration) -> bool {
self.labels.iter().all(|wanted| {
let wanted = normalized(wanted);
initiative
.labels
.iter()
.any(|actual| normalized(actual) == wanted)
})
}
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()))
}
}
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)
}
}