use std::time::Duration;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use super::{BusinessCalendar, BusinessDuration};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Obligation {
name: String,
subject: String,
within: BusinessDuration,
calendar: String,
zone: Option<Tz>,
started_at: Option<DateTime<Utc>>,
met_at: Option<DateTime<Utc>>,
}
impl Obligation {
pub const DEFAULT_CALENDAR: &'static str = "default";
#[must_use]
pub fn new(name: impl Into<String>, subject: impl Into<String>) -> Self {
Self {
name: name.into(),
subject: subject.into(),
within: BusinessDuration::ZERO,
calendar: Self::DEFAULT_CALENDAR.to_owned(),
zone: None,
started_at: None,
met_at: None,
}
}
#[must_use]
pub const fn within(mut self, budget: BusinessDuration) -> Self {
self.within = budget;
self
}
#[must_use]
pub fn calendar(mut self, name: impl Into<String>) -> Self {
self.calendar = name.into();
self
}
#[must_use]
pub const fn zone(mut self, zone: Tz) -> Self {
self.zone = Some(zone);
self
}
#[must_use]
pub fn zone_from<Z: ObligationZone + ?Sized>(mut self, zone: &Z) -> Self {
self.zone = zone.obligation_zone().or(self.zone);
self
}
#[must_use]
pub const fn starting_at(mut self, at: DateTime<Utc>) -> Self {
self.started_at = Some(at);
self
}
#[must_use]
pub fn met_at(mut self, at: impl Into<Option<DateTime<Utc>>>) -> Self {
self.met_at = at.into();
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
pub fn key(&self) -> String {
let name = self.name.replace('%', "%25").replace('/', "%2F");
format!("{name}/{}", self.subject)
}
#[must_use]
pub const fn budget(&self) -> BusinessDuration {
self.within
}
#[must_use]
pub fn calendar_name(&self) -> &str {
&self.calendar
}
#[must_use]
pub const fn time_zone(&self) -> Option<Tz> {
self.zone
}
#[must_use]
pub const fn started_at(&self) -> Option<DateTime<Utc>> {
self.started_at
}
#[must_use]
pub const fn met(&self) -> Option<DateTime<Utc>> {
self.met_at
}
#[must_use]
pub fn status_with(
&self,
calendar: &BusinessCalendar,
zone: Tz,
now: DateTime<Utc>,
) -> ObligationStatus {
let started_at = self.started_at.unwrap_or(now);
let budget = self.within.resolve(calendar);
let due_at = calendar.deadline(started_at, budget, zone);
self.status_at(calendar, zone, now, due_at)
}
pub(super) fn status_with_due(
&self,
calendar: &BusinessCalendar,
zone: Tz,
now: DateTime<Utc>,
due_at: DateTime<Utc>,
) -> ObligationStatus {
let mut status = self.status_at(calendar, zone, now, Some(due_at));
if matches!(
status.state,
ObligationState::Running | ObligationState::Paused
) {
status.remaining = calendar.working_time(now.max(status.started_at), due_at, zone);
}
status
}
pub(super) fn status_without_due(
&self,
calendar: &BusinessCalendar,
zone: Tz,
now: DateTime<Utc>,
) -> ObligationStatus {
self.status_at(calendar, zone, now, None)
}
fn status_at(
&self,
calendar: &BusinessCalendar,
zone: Tz,
now: DateTime<Utc>,
due_at: Option<DateTime<Utc>>,
) -> ObligationStatus {
let started_at = self.started_at.unwrap_or(now);
let budget = self.within.resolve(calendar);
let met_at = self.met_at.filter(|met| *met <= now);
let elapsed = calendar.working_time(started_at, met_at.unwrap_or(now), zone);
let breached = match (due_at, met_at) {
(Some(due), Some(met)) => met > due,
(Some(due), None) => now >= due,
(None, _) => false,
};
let state = if breached {
ObligationState::Breached
} else if met_at.is_some() {
ObligationState::Met
} else if now >= started_at && calendar.is_working(now, zone) {
ObligationState::Running
} else {
ObligationState::Paused
};
let resumes_at = (state == ObligationState::Paused)
.then(|| calendar.next_working_instant(now.max(started_at), zone))
.flatten();
ObligationStatus {
key: self.key(),
state,
zone,
started_at,
due_at,
met_at,
escalated_at: None,
resumes_at,
budget,
elapsed,
remaining: if breached {
Duration::ZERO
} else {
budget.saturating_sub(elapsed)
},
}
}
pub(super) const fn set_met(&mut self, at: DateTime<Utc>) {
self.met_at = Some(at);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ObligationState {
Running,
Paused,
Met,
Breached,
}
impl ObligationState {
#[must_use]
pub const fn is_breached(self) -> bool {
matches!(self, Self::Breached)
}
#[must_use]
pub const fn is_open(self) -> bool {
matches!(self, Self::Running | Self::Paused)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct ObligationStatus {
pub key: String,
pub state: ObligationState,
#[serde(serialize_with = "zone_name")]
pub zone: Tz,
pub started_at: DateTime<Utc>,
pub due_at: Option<DateTime<Utc>>,
pub met_at: Option<DateTime<Utc>>,
pub escalated_at: Option<DateTime<Utc>>,
pub resumes_at: Option<DateTime<Utc>>,
#[serde(serialize_with = "whole_seconds")]
pub budget: Duration,
#[serde(serialize_with = "whole_seconds")]
pub elapsed: Duration,
#[serde(serialize_with = "whole_seconds")]
pub remaining: Duration,
}
#[allow(
clippy::trivially_copy_pass_by_ref,
reason = "serde `serialize_with` passes a reference"
)]
fn zone_name<S: serde::Serializer>(zone: &Tz, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(zone.name())
}
fn whole_seconds<S: serde::Serializer>(
duration: &Duration,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_u64(duration.as_secs())
}
pub trait ObligationZone {
fn obligation_zone(&self) -> Option<Tz>;
}
impl ObligationZone for Tz {
fn obligation_zone(&self) -> Option<Tz> {
Some(*self)
}
}
impl ObligationZone for crate::time_zone::TimeZone {
fn obligation_zone(&self) -> Option<Tz> {
Some(self.tz())
}
}
impl ObligationZone for str {
fn obligation_zone(&self) -> Option<Tz> {
crate::time_zone::parse_iana(self)
}
}
impl ObligationZone for String {
fn obligation_zone(&self) -> Option<Tz> {
self.as_str().obligation_zone()
}
}
impl<Z: ObligationZone> ObligationZone for Option<Z> {
fn obligation_zone(&self) -> Option<Tz> {
self.as_ref().and_then(ObligationZone::obligation_zone)
}
}
impl<Z: ObligationZone + ?Sized> ObligationZone for &Z {
fn obligation_zone(&self) -> Option<Tz> {
(**self).obligation_zone()
}
}