use std::cmp::Ordering;
use std::fmt;
use crate::assignee::parse_assignee;
use crate::model::validate_date;
use crate::time_tracking::entry::TimeEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeriodPlacement {
InPeriod,
OutOfPeriod,
Undated,
Unreadable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeFilterError {
InvalidDate {
field: &'static str,
date: String,
},
InvertedPeriod {
date_from: String,
date_to: String,
},
InvalidAssignee {
assignee: String,
},
}
impl TimeFilterError {
pub const fn kind(&self) -> &'static str {
match self {
Self::InvalidDate { .. } => "invalid_date",
Self::InvertedPeriod { .. } => "invalid_period",
Self::InvalidAssignee { .. } => "invalid_assignee",
}
}
pub const fn field(&self) -> &'static str {
match self {
Self::InvalidDate { field, .. } => field,
Self::InvertedPeriod { .. } => "period",
Self::InvalidAssignee { .. } => "assignee",
}
}
pub fn message(&self) -> String {
match self {
Self::InvalidDate { field, date } => format!(
"`{field}` {date:?} is not a `YYYY-MM-DD` calendar date (year >= 1970), so no \
period bound was applied"
),
Self::InvertedPeriod { date_from, date_to } => format!(
"`date_from` {date_from} is later than `date_to` {date_to}, so the period holds no \
date"
),
Self::InvalidAssignee { assignee } => format!(
"assignee {assignee:?} names no identity, so no entry can match the criterion"
),
}
}
pub const fn remedy(&self) -> &'static str {
match self {
Self::InvalidDate { .. } => {
"write the bound as `YYYY-MM-DD` (year >= 1970), or drop it"
}
Self::InvertedPeriod { .. } => {
"swap the bounds: the period is inclusive and its start must not be later than its \
end"
}
Self::InvalidAssignee { .. } => {
"name an identity (`ada`, `@ada`, or `Ada Lovelace <ada@example.com>`), or drop \
the criterion"
}
}
}
}
impl fmt::Display for TimeFilterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.kind(), self.message())
}
}
impl std::error::Error for TimeFilterError {}
pub fn canonical_identity(raw: &str) -> Option<String> {
parse_assignee(raw)
}
pub fn identity_cmp(a: &str, b: &str) -> Ordering {
let mut left = a.bytes().map(|byte| byte.to_ascii_lowercase());
let mut right = b.bytes().map(|byte| byte.to_ascii_lowercase());
loop {
match (left.next(), right.next()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(x), Some(y)) => match x.cmp(&y) {
Ordering::Equal => {}
other => return other,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TimeFilter {
pub issue: Option<u32>,
pub assignee: Option<String>,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub include_deleted: bool,
}
impl TimeFilter {
pub const fn new() -> Self {
Self {
issue: None,
assignee: None,
date_from: None,
date_to: None,
include_deleted: false,
}
}
pub fn for_issue(mut self, number: u32) -> Self {
self.issue = Some(number);
self
}
pub fn for_assignee(mut self, assignee: impl Into<String>) -> Self {
self.assignee = Some(assignee.into());
self
}
pub fn since(mut self, date: impl Into<String>) -> Self {
self.date_from = Some(date.into());
self
}
pub fn until(mut self, date: impl Into<String>) -> Self {
self.date_to = Some(date.into());
self
}
pub fn between(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
self.date_from = Some(start.into());
self.date_to = Some(end.into());
self
}
pub fn including_deleted(mut self) -> Self {
self.include_deleted = true;
self
}
pub const fn is_unfiltered(&self) -> bool {
self.issue.is_none()
&& self.assignee.is_none()
&& self.date_from.is_none()
&& self.date_to.is_none()
}
pub const fn has_period(&self) -> bool {
self.date_from.is_some() || self.date_to.is_some()
}
pub fn problems(&self) -> Vec<TimeFilterError> {
let mut problems = Vec::new();
for (field, bound) in [("date_from", &self.date_from), ("date_to", &self.date_to)] {
if let Some(date) = bound.as_deref()
&& !validate_date(date)
{
problems.push(TimeFilterError::InvalidDate {
field,
date: date.to_string(),
});
}
}
if let (Some(from), Some(to)) = (self.date_from.as_deref(), self.date_to.as_deref())
&& validate_date(from)
&& validate_date(to)
&& from > to
{
problems.push(TimeFilterError::InvertedPeriod {
date_from: from.to_string(),
date_to: to.to_string(),
});
}
if let Some(assignee) = self.assignee.as_deref()
&& canonical_identity(assignee).is_none()
{
problems.push(TimeFilterError::InvalidAssignee {
assignee: assignee.to_string(),
});
}
problems
}
pub fn validate(&self) -> Result<(), Vec<TimeFilterError>> {
let problems = self.problems();
if problems.is_empty() {
Ok(())
} else {
Err(problems)
}
}
pub fn is_valid(&self) -> bool {
self.problems().is_empty()
}
pub fn canonical_assignee(&self) -> Option<String> {
self.assignee.as_deref().and_then(canonical_identity)
}
pub fn place(&self, date: Option<&str>) -> PeriodPlacement {
if !self.has_period() {
return PeriodPlacement::InPeriod;
}
let Some(date) = date else {
return PeriodPlacement::Undated;
};
if !validate_date(date) {
return PeriodPlacement::Unreadable;
}
let after_from = self.date_from.as_deref().is_none_or(|from| date >= from);
let before_to = self.date_to.as_deref().is_none_or(|to| date <= to);
if after_from && before_to {
PeriodPlacement::InPeriod
} else {
PeriodPlacement::OutOfPeriod
}
}
pub fn selects(&self, issue: u32, entry: &TimeEntry) -> bool {
if !self.is_valid() {
return false;
}
if let Some(number) = self.issue
&& number != issue
{
return false;
}
if let Some(criterion) = self.canonical_assignee() {
let Some(author) = canonical_identity(&entry.author) else {
return false;
};
if identity_cmp(&author, &criterion) != Ordering::Equal {
return false;
}
}
self.place(entry.date.as_deref()) == PeriodPlacement::InPeriod
}
pub fn describe(&self) -> String {
let mut parts = Vec::new();
if let Some(issue) = self.issue {
parts.push(format!("issue #{issue}"));
}
if let Some(assignee) = self.canonical_assignee() {
parts.push(format!(
"assignee {}",
crate::assignee::assignee_display(&assignee)
));
}
match (self.date_from.as_deref(), self.date_to.as_deref()) {
(Some(from), Some(to)) => parts.push(format!("period {from}..{to}")),
(Some(from), None) => parts.push(format!("period {from}..")),
(None, Some(to)) => parts.push(format!("period ..{to}")),
(None, None) => {}
}
if self.include_deleted {
parts.push("audit".to_string());
}
if parts.is_empty() {
return "all".to_string();
}
parts.join(", ")
}
}
impl fmt::Display for TimeFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.describe())
}
}