use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::model::validate_date;
pub const TIME_ENTRY_FRONTMATTER_KEY: &str = "time_entries";
pub const TIME_ENTRY_KNOWN_FIELDS: &[&str] = &["id", "duration_minutes", "date", "author", "note"];
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TimeEntryExtensions(BTreeMap<String, serde_yaml::Value>);
impl TimeEntryExtensions {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn get(&self, key: &str) -> Option<&serde_yaml::Value> {
self.0.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.0.keys().map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &serde_yaml::Value)> {
self.0.iter().map(|(key, value)| (key.as_str(), value))
}
pub fn as_map(&self) -> &BTreeMap<String, serde_yaml::Value> {
&self.0
}
pub fn insert(
&mut self,
key: impl Into<String>,
value: serde_yaml::Value,
) -> Option<serde_yaml::Value> {
self.0.insert(key.into(), value)
}
pub fn canonical_yaml(&self) -> String {
serde_yaml::to_string(&self.0).unwrap_or_default()
}
}
impl PartialEq for TimeEntryExtensions {
fn eq(&self, other: &Self) -> bool {
self.canonical_yaml() == other.canonical_yaml()
}
}
impl Eq for TimeEntryExtensions {}
impl From<BTreeMap<String, serde_yaml::Value>> for TimeEntryExtensions {
fn from(map: BTreeMap<String, serde_yaml::Value>) -> Self {
Self(map)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeEntry {
#[serde(default)]
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_minutes: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(default)]
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(flatten)]
pub extensions: TimeEntryExtensions,
}
impl TimeEntry {
pub fn new(
id: impl Into<String>,
duration_minutes: i64,
date: Option<String>,
author: impl Into<String>,
note: Option<String>,
) -> Result<Self, Vec<TimeEntryError>> {
let entry = Self {
id: id.into(),
duration_minutes: Some(duration_minutes),
date,
author: author.into(),
note,
extensions: TimeEntryExtensions::default(),
};
entry.validate().map(|()| entry)
}
pub fn minutes(&self) -> Option<i64> {
self.duration_minutes.filter(|minutes| *minutes >= 0)
}
pub fn order_key(&self) -> (bool, Option<&str>, &str) {
(self.date.is_none(), self.date.as_deref(), self.id.as_str())
}
pub fn problems(&self) -> Vec<TimeEntryError> {
let mut problems = Vec::new();
if self.id.trim().is_empty() {
problems.push(TimeEntryError::MissingId {
id: self.id.clone(),
});
}
if self.minutes().is_none() {
problems.push(TimeEntryError::InvalidDuration {
duration_minutes: self.duration_minutes,
});
}
if let Some(date) = &self.date
&& !validate_date(date)
{
problems.push(TimeEntryError::InvalidDate { date: date.clone() });
}
if self.author.trim().is_empty() {
problems.push(TimeEntryError::MissingAuthor);
}
if let Some(note) = &self.note
&& note.contains(['\n', '\r'])
{
problems.push(TimeEntryError::NoteNotSingleLine);
}
for key in self.extensions.keys() {
if TIME_ENTRY_KNOWN_FIELDS.contains(&key) {
problems.push(TimeEntryError::ExtensionShadowsField {
key: key.to_string(),
});
}
}
problems
}
pub fn is_valid(&self) -> bool {
self.problems().is_empty()
}
pub fn validate(&self) -> Result<(), Vec<TimeEntryError>> {
let problems = self.problems();
if problems.is_empty() {
Ok(())
} else {
Err(problems)
}
}
}
impl PartialOrd for TimeEntry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TimeEntry {
fn cmp(&self, other: &Self) -> Ordering {
self.order_key()
.cmp(&other.order_key())
.then_with(|| self.duration_minutes.cmp(&other.duration_minutes))
.then_with(|| self.author.cmp(&other.author))
.then_with(|| self.note.cmp(&other.note))
.then_with(|| {
self.extensions
.canonical_yaml()
.cmp(&other.extensions.canonical_yaml())
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeEntryError {
MissingId { id: String },
InvalidDuration { duration_minutes: Option<i64> },
InvalidDate { date: String },
MissingAuthor,
NoteNotSingleLine,
ExtensionShadowsField { key: String },
}
impl TimeEntryError {
pub fn kind(&self) -> &'static str {
match self {
Self::InvalidDuration { .. } => "invalid_duration",
Self::InvalidDate { .. } => "invalid_date",
Self::MissingAuthor => "missing_author",
Self::MissingId { .. }
| Self::NoteNotSingleLine
| Self::ExtensionShadowsField { .. } => "malformed_entry",
}
}
pub fn field(&self) -> &'static str {
match self {
Self::MissingId { .. } => "id",
Self::InvalidDuration { .. } => "duration_minutes",
Self::InvalidDate { .. } => "date",
Self::MissingAuthor => "author",
Self::NoteNotSingleLine => "note",
Self::ExtensionShadowsField { .. } => "extensions",
}
}
}
impl fmt::Display for TimeEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingId { .. } => {
f.write_str("the entry has no stable id; give it one that is never reused")
}
Self::InvalidDuration {
duration_minutes: Some(minutes),
} => write!(
f,
"duration_minutes {minutes} is negative; write a non-negative number of whole minutes"
),
Self::InvalidDuration {
duration_minutes: None,
} => f.write_str(
"duration_minutes is missing; write a non-negative number of whole minutes",
),
Self::InvalidDate { date } => write!(
f,
"date {date:?} is not a `YYYY-MM-DD` calendar date (year >= 1970)"
),
Self::MissingAuthor => f.write_str(
"the entry has no author; an entry without attribution is not auditable",
),
Self::NoteNotSingleLine => {
f.write_str("note contains a line break; the History detail is one line")
}
Self::ExtensionShadowsField { key } => write!(
f,
"preserved key {key:?} would shadow a typed field; rename it, or model it"
),
}
}
}
impl std::error::Error for TimeEntryError {}