use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use crate::model::{HistoryEventKind, Issue, validate_date};
use crate::time_tracking::diagnostics::{TimeDiagnostic, TimeDiagnosticKind};
use crate::time_tracking::entry::TimeEntry;
use crate::time_tracking::filters::{
PeriodPlacement, TimeFilter, canonical_identity, identity_cmp,
};
use crate::time_tracking::service::entry_id_in;
#[derive(Debug, Clone)]
pub enum TimeGroup {
All,
Issue(u32),
Assignee(String),
Unattributed,
Date(String),
Undated,
}
impl TimeGroup {
pub fn label(&self) -> String {
match self {
Self::All => "all".to_string(),
Self::Issue(number) => format!("#{number}"),
Self::Assignee(identity) => crate::assignee::assignee_display(identity),
Self::Unattributed => "(unattributed)".to_string(),
Self::Date(date) => date.clone(),
Self::Undated => "(undated)".to_string(),
}
}
}
impl PartialEq for TimeGroup {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for TimeGroup {}
impl PartialOrd for TimeGroup {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TimeGroup {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(Self::All, Self::All) => Ordering::Equal,
(Self::All, _) => Ordering::Less,
(_, Self::All) => Ordering::Greater,
(Self::Issue(left), Self::Issue(right)) => left.cmp(right),
(Self::Issue(_), _) => Ordering::Less,
(_, Self::Issue(_)) => Ordering::Greater,
(Self::Assignee(left), Self::Assignee(right)) => identity_cmp(left, right),
(Self::Assignee(_), _) => Ordering::Less,
(_, Self::Assignee(_)) => Ordering::Greater,
(Self::Unattributed, Self::Unattributed) => Ordering::Equal,
(Self::Unattributed, _) => Ordering::Less,
(_, Self::Unattributed) => Ordering::Greater,
(Self::Date(left), Self::Date(right)) => left.cmp(right),
(Self::Date(_), Self::Undated) => Ordering::Less,
(Self::Undated, Self::Date(_)) => Ordering::Greater,
(Self::Undated, Self::Undated) => Ordering::Equal,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TimeTotalState {
InvalidFilter,
UnknownIssue,
Nothing,
Unknown,
Recorded,
}
impl TimeTotalState {
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidFilter => "invalid_filter",
Self::UnknownIssue => "unknown_issue",
Self::Nothing => "nothing",
Self::Unknown => "unknown",
Self::Recorded => "recorded",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeTotal {
pub filter: TimeFilter,
pub group: TimeGroup,
pub total_minutes: Option<i128>,
pub entry_count: usize,
pub malformed_count: usize,
pub undated_count: usize,
pub deleted_count: Option<usize>,
pub diagnostics: Vec<TimeDiagnostic>,
}
impl TimeTotal {
pub fn state(&self) -> TimeTotalState {
if self.diagnostics.iter().any(|finding| {
finding.is_scope_finding() && finding.kind == TimeDiagnosticKind::UnknownIssue
}) {
return TimeTotalState::UnknownIssue;
}
if self
.diagnostics
.iter()
.any(TimeDiagnostic::is_scope_finding)
{
return TimeTotalState::InvalidFilter;
}
if self.total_minutes.is_some() {
TimeTotalState::Recorded
} else if self.malformed_count > 0 || self.undated_count > 0 {
TimeTotalState::Unknown
} else {
TimeTotalState::Nothing
}
}
pub const fn is_partial(&self) -> bool {
self.malformed_count > 0 || self.undated_count > 0
}
fn bare(filter: TimeFilter, group: TimeGroup, diagnostics: Vec<TimeDiagnostic>) -> Self {
Self {
filter,
group,
total_minutes: None,
entry_count: 0,
malformed_count: 0,
undated_count: 0,
deleted_count: None,
diagnostics,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct IndexedEntry {
pub issue: u32,
pub entry: TimeEntry,
pub canonical_author: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeDeletion {
pub issue: u32,
pub entry_id: Option<String>,
pub deleted_at: String,
pub deleted_by: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueTimeAudit {
pub issue: u32,
pub current: usize,
pub deletions: Vec<TimeDeletion>,
pub corrections: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeAudit {
pub filter: TimeFilter,
pub issues: Vec<IssueTimeAudit>,
pub deleted_count: usize,
pub corrected_count: usize,
pub diagnostics: Vec<TimeDiagnostic>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TimeSnapshot {
entries: Vec<IndexedEntry>,
issues: Vec<u32>,
deletions: BTreeMap<u32, Vec<TimeDeletion>>,
corrections: BTreeMap<u32, usize>,
}
impl TimeSnapshot {
pub fn of(issues: &[Issue]) -> Self {
let mut entries = Vec::new();
let mut numbers: Vec<u32> = Vec::with_capacity(issues.len());
let mut deletions: BTreeMap<u32, Vec<TimeDeletion>> = BTreeMap::new();
let mut corrections: BTreeMap<u32, usize> = BTreeMap::new();
for issue in issues {
numbers.push(issue.number);
entries.extend(issue.time_entries.iter().map(|entry| IndexedEntry {
issue: issue.number,
canonical_author: canonical_identity(&entry.author),
entry: entry.clone(),
}));
let mut deleted = Vec::new();
let mut corrected = 0usize;
for event in issue.history.events() {
match event.kind {
HistoryEventKind::TimeEntryDeleted => deleted.push(TimeDeletion {
issue: issue.number,
entry_id: entry_id_in(&event.detail).map(str::to_string),
deleted_at: event.timestamp.clone(),
deleted_by: event.actor.clone(),
}),
HistoryEventKind::TimeEntryUpdated => corrected += 1,
_ => {}
}
}
if !deleted.is_empty() {
deletions.insert(issue.number, deleted);
}
if corrected > 0 {
corrections.insert(issue.number, corrected);
}
}
numbers.sort_unstable();
numbers.dedup();
entries.sort();
Self {
entries,
issues: numbers,
deletions,
corrections,
}
}
pub fn entries(&self) -> &[IndexedEntry] {
&self.entries
}
pub fn issues(&self) -> &[u32] {
&self.issues
}
pub fn holds_issue(&self, number: u32) -> bool {
self.issues.binary_search(&number).is_ok()
}
pub fn deletions_on(&self, issue: u32) -> &[TimeDeletion] {
self.deletions
.get(&issue)
.map_or([].as_slice(), Vec::as_slice)
}
pub fn corrections_on(&self, issue: u32) -> usize {
self.corrections.get(&issue).copied().unwrap_or(0)
}
fn scope_issues<'a>(&'a self, filter: &'a TimeFilter) -> impl Iterator<Item = u32> + 'a {
self.issues
.iter()
.copied()
.filter(move |number| filter.issue.is_none_or(|selected| selected == *number))
}
fn scope_deletions(&self, filter: &TimeFilter) -> usize {
self.scope_issues(filter)
.map(|number| self.deletions_on(number).len())
.sum()
}
pub fn total(&self, filter: &TimeFilter) -> TimeTotal {
if let Some(refused) = self.refusal(filter) {
return refused;
}
let matcher = Matcher::new(filter);
let mut totals = Totals::default();
for indexed in &self.entries {
if !matcher.matches(indexed) {
continue;
}
let placement = filter.place(indexed.entry.date.as_deref());
totals.fold(indexed, placement);
}
totals.into_total(
self,
filter.clone(),
TimeGroup::All,
DeletionScope::Selector,
)
}
pub fn totals(&self, filters: &[TimeFilter]) -> Vec<TimeTotal> {
filters.iter().map(|filter| self.total(filter)).collect()
}
pub fn by_issue(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
if let Some(refused) = self.refusal(filter) {
return vec![refused];
}
let matcher = Matcher::new(filter);
let mut groups: BTreeMap<u32, Totals> = BTreeMap::new();
for number in &self.issues {
if matcher.holds_issue(*number) {
groups.insert(*number, Totals::default());
}
}
for indexed in &self.entries {
if !matcher.matches(indexed) {
continue;
}
let placement = filter.place(indexed.entry.date.as_deref());
if let Some(totals) = groups.get_mut(&indexed.issue) {
totals.fold(indexed, placement);
}
}
groups
.into_iter()
.map(|(number, totals)| {
totals.into_total(
self,
filter.clone(),
TimeGroup::Issue(number),
DeletionScope::Issue(number),
)
})
.collect()
}
pub fn by_assignee(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
if let Some(refused) = self.refusal(filter) {
return vec![refused];
}
let matcher = Matcher::new(filter);
let mut groups: BTreeMap<TimeGroup, Totals> = BTreeMap::new();
for indexed in &self.entries {
if !matcher.matches(indexed) {
continue;
}
let key = match &indexed.canonical_author {
Some(identity) => TimeGroup::Assignee(identity.clone()),
None => TimeGroup::Unattributed,
};
let placement = filter.place(indexed.entry.date.as_deref());
groups.entry(key).or_default().fold(indexed, placement);
}
groups
.into_iter()
.map(|(group, totals)| {
totals.into_total(self, filter.clone(), group, DeletionScope::Drawn)
})
.collect()
}
pub fn by_date(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
if let Some(refused) = self.refusal(filter) {
return vec![refused];
}
let matcher = Matcher::new(filter);
let mut groups: BTreeMap<TimeGroup, Totals> = BTreeMap::new();
for indexed in &self.entries {
if !matcher.matches(indexed) {
continue;
}
let placement = filter.place(indexed.entry.date.as_deref());
if placement == PeriodPlacement::OutOfPeriod {
continue;
}
let key = match indexed.entry.date.as_deref() {
Some(date) if validate_date(date) => TimeGroup::Date(date.to_string()),
_ => TimeGroup::Undated,
};
groups.entry(key).or_default().fold(indexed, placement);
}
groups
.into_iter()
.map(|(group, totals)| {
totals.into_total(self, filter.clone(), group, DeletionScope::Drawn)
})
.collect()
}
pub fn audit(&self, filter: &TimeFilter) -> TimeAudit {
let problems = filter.problems();
if !problems.is_empty() {
return TimeAudit {
filter: filter.clone(),
issues: Vec::new(),
deleted_count: 0,
corrected_count: 0,
diagnostics: problems
.iter()
.map(TimeDiagnostic::from_filter_problem)
.collect(),
};
}
let mut current: BTreeMap<u32, usize> = BTreeMap::new();
for indexed in &self.entries {
*current.entry(indexed.issue).or_default() += 1;
}
let mut issues = Vec::new();
let mut deleted_count = 0;
let mut corrected_count = 0;
for number in self.scope_issues(filter) {
let deletions = self.deletions_on(number).to_vec();
deleted_count += deletions.len();
corrected_count += self.corrections_on(number);
issues.push(IssueTimeAudit {
issue: number,
current: current.get(&number).copied().unwrap_or(0),
deletions,
corrections: self.corrections_on(number),
});
}
let mut diagnostics = Vec::new();
if let Some(number) = filter.issue
&& !self.holds_issue(number)
{
diagnostics.push(TimeDiagnostic::unknown_issue(number));
}
TimeAudit {
filter: filter.clone(),
issues,
deleted_count,
corrected_count,
diagnostics,
}
}
fn refusal(&self, filter: &TimeFilter) -> Option<TimeTotal> {
let problems = filter.problems();
if !problems.is_empty() {
return Some(TimeTotal::bare(
filter.clone(),
TimeGroup::All,
problems
.iter()
.map(TimeDiagnostic::from_filter_problem)
.collect(),
));
}
let number = filter.issue.filter(|number| !self.holds_issue(*number))?;
Some(TimeTotal::bare(
filter.clone(),
TimeGroup::All,
vec![TimeDiagnostic::unknown_issue(number)],
))
}
}
struct Matcher<'a> {
filter: &'a TimeFilter,
assignee: Option<String>,
}
impl<'a> Matcher<'a> {
fn new(filter: &'a TimeFilter) -> Self {
Self {
filter,
assignee: filter.canonical_assignee(),
}
}
fn holds_issue(&self, number: u32) -> bool {
self.filter.issue.is_none_or(|selected| selected == number)
}
fn matches(&self, indexed: &IndexedEntry) -> bool {
if !self.holds_issue(indexed.issue) {
return false;
}
let Some(criterion) = self.assignee.as_deref() else {
return true;
};
match indexed.canonical_author.as_deref() {
Some(author) => identity_cmp(author, criterion) == Ordering::Equal,
None => false,
}
}
}
enum DeletionScope {
Selector,
Issue(u32),
Drawn,
}
#[derive(Default)]
struct Totals {
minutes: i128,
entry_count: usize,
malformed_count: usize,
undated_count: usize,
diagnostics: Vec<TimeDiagnostic>,
drawn: BTreeSet<u32>,
}
impl Totals {
fn fold(&mut self, indexed: &IndexedEntry, placement: PeriodPlacement) {
self.drawn.insert(indexed.issue);
let problems = indexed.entry.problems();
if !problems.is_empty() {
self.malformed_count += 1;
self.diagnostics.extend(problems.iter().map(|problem| {
TimeDiagnostic::from_entry_problem(indexed.issue, &indexed.entry.id, problem)
}));
return;
}
let Some(minutes) = indexed.entry.minutes() else {
self.malformed_count += 1;
self.diagnostics.push(
TimeDiagnostic::new(
TimeDiagnosticKind::InvalidDuration,
"the entry carries no usable `duration_minutes` value",
)
.for_entry(indexed.issue, &indexed.entry.id)
.in_field("duration_minutes"),
);
return;
};
match placement {
PeriodPlacement::InPeriod => {
self.minutes += i128::from(minutes);
self.entry_count += 1;
}
PeriodPlacement::Undated => self.undated_count += 1,
PeriodPlacement::OutOfPeriod | PeriodPlacement::Unreadable => {}
}
}
fn into_total(
self,
snapshot: &TimeSnapshot,
filter: TimeFilter,
group: TimeGroup,
deletion_scope: DeletionScope,
) -> TimeTotal {
let total_minutes = (self.entry_count > 0).then_some(self.minutes);
let deleted_count = filter.include_deleted.then(|| match deletion_scope {
DeletionScope::Selector => snapshot.scope_deletions(&filter),
DeletionScope::Issue(number) => snapshot.deletions_on(number).len(),
DeletionScope::Drawn => self
.drawn
.iter()
.map(|number| snapshot.deletions_on(*number).len())
.sum(),
});
TimeTotal {
filter,
group,
total_minutes,
entry_count: self.entry_count,
malformed_count: self.malformed_count,
undated_count: self.undated_count,
deleted_count,
diagnostics: self.diagnostics,
}
}
}