use std::fmt;
use crate::error::{Result, TixError};
use crate::model::{HistoryEvent, HistoryEventKind, Issue, utc_now_rfc3339};
use crate::storage::Store;
use crate::time_tracking::entry::{TimeEntry, TimeEntryError};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Patch<T> {
#[default]
Unchanged,
Clear,
Set(T),
}
impl<T> Patch<T> {
pub fn is_unchanged(&self) -> bool {
matches!(self, Patch::Unchanged)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewTimeEntry {
pub id: Option<String>,
pub duration_minutes: i64,
pub date: Option<String>,
pub author: String,
pub note: Option<String>,
}
impl NewTimeEntry {
pub fn new(duration_minutes: i64, author: impl Into<String>) -> Self {
Self {
id: None,
duration_minutes,
date: None,
author: author.into(),
note: None,
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn on(mut self, date: impl Into<String>) -> Self {
self.date = Some(date.into());
self
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
fn resolve(self) -> TimeEntry {
TimeEntry {
id: self.id.unwrap_or_else(new_entry_id),
duration_minutes: Some(self.duration_minutes),
date: self.date,
author: self.author,
note: self.note,
extensions: crate::time_tracking::TimeEntryExtensions::default(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TimeEntryEdit {
pub duration_minutes: Patch<i64>,
pub date: Patch<String>,
pub author: Patch<String>,
pub note: Patch<String>,
}
impl TimeEntryEdit {
pub fn new() -> Self {
Self::default()
}
pub fn duration(mut self, duration_minutes: i64) -> Self {
self.duration_minutes = Patch::Set(duration_minutes);
self
}
pub fn date(mut self, date: impl Into<String>) -> Self {
self.date = Patch::Set(date.into());
self
}
pub fn clear_date(mut self) -> Self {
self.date = Patch::Clear;
self
}
pub fn author(mut self, author: impl Into<String>) -> Self {
self.author = Patch::Set(author.into());
self
}
pub fn note(mut self, note: impl Into<String>) -> Self {
self.note = Patch::Set(note.into());
self
}
pub fn clear_note(mut self) -> Self {
self.note = Patch::Clear;
self
}
pub fn is_empty(&self) -> bool {
self.duration_minutes.is_unchanged()
&& self.date.is_unchanged()
&& self.author.is_unchanged()
&& self.note.is_unchanged()
}
fn apply_to(&self, before: &TimeEntry) -> TimeEntry {
let mut after = before.clone();
if let Patch::Set(minutes) = self.duration_minutes {
after.duration_minutes = Some(minutes);
}
match &self.date {
Patch::Unchanged => {}
Patch::Clear => after.date = None,
Patch::Set(date) => after.date = Some(date.clone()),
}
match &self.author {
Patch::Unchanged => {}
Patch::Clear => after.author = String::new(),
Patch::Set(author) => after.author = author.clone(),
}
match &self.note {
Patch::Unchanged => {}
Patch::Clear => after.note = None,
Patch::Set(note) => after.note = Some(note.clone()),
}
after
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeIntent {
Create(NewTimeEntry),
Edit {
entry_id: String,
change: TimeEntryEdit,
},
Delete {
entry_id: String,
},
}
impl TimeIntent {
pub fn entry_id(&self) -> Option<&str> {
match self {
TimeIntent::Create(new) => new.id.as_deref(),
TimeIntent::Edit { entry_id, .. } | TimeIntent::Delete { entry_id } => {
Some(entry_id.as_str())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeAction {
Create,
Edit,
Delete,
}
impl TimeAction {
fn of(intent: &TimeIntent) -> Self {
match intent {
TimeIntent::Create(_) => TimeAction::Create,
TimeIntent::Edit { .. } => TimeAction::Edit,
TimeIntent::Delete { .. } => TimeAction::Delete,
}
}
fn kind(self) -> HistoryEventKind {
match self {
TimeAction::Create => HistoryEventKind::TimeEntryCreated,
TimeAction::Edit => HistoryEventKind::TimeEntryUpdated,
TimeAction::Delete => HistoryEventKind::TimeEntryDeleted,
}
}
fn word(self) -> &'static str {
match self {
TimeAction::Create => "created",
TimeAction::Edit => "updated",
TimeAction::Delete => "deleted",
}
}
fn label(self) -> &'static str {
match self {
TimeAction::Create => "create",
TimeAction::Edit => "edit",
TimeAction::Delete => "delete",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TimeAttribution {
pub actor: Option<String>,
pub timestamp: Option<String>,
pub reason: Option<String>,
}
impl TimeAttribution {
pub fn new() -> Self {
Self::default()
}
pub fn by(mut self, actor: impl Into<String>) -> Self {
self.actor = Some(actor.into());
self
}
pub fn at(mut self, timestamp: impl Into<String>) -> Self {
self.timestamp = Some(timestamp.into());
self
}
pub fn because(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
fn actor(&self) -> std::result::Result<String, TimeMutationError> {
let actor = match self.actor.as_deref() {
Some(actor) => actor.trim().to_string(),
None => crate::storage::actor_identity(),
};
if actor.is_empty() {
return Err(TimeMutationError::MissingActor);
}
check_history_text("actor", &actor, false)?;
Ok(actor)
}
fn timestamp(&self) -> std::result::Result<String, TimeMutationError> {
match self.timestamp.as_deref().map(str::trim) {
Some(timestamp) => {
let timestamp = timestamp.to_string();
if !crate::cycle_schedule::snapshot::validate_timestamp(×tamp) {
return Err(TimeMutationError::InvalidTimestamp { timestamp });
}
Ok(timestamp)
}
None => Ok(utc_now_rfc3339()),
}
}
fn reason(&self, action: TimeAction) -> std::result::Result<Option<String>, TimeMutationError> {
let reason = self
.reason
.as_deref()
.map(str::trim)
.filter(|reason| !reason.is_empty());
match (reason, action) {
(Some(reason), _) => {
check_history_text("reason", reason, false)?;
Ok(Some(reason.to_string()))
}
(None, TimeAction::Create) => Ok(None),
(None, action) => Err(TimeMutationError::MissingReason {
action: action.label(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeMutationRequest {
pub issue: String,
pub intent: TimeIntent,
pub attribution: TimeAttribution,
}
impl TimeMutationRequest {
pub fn create(issue: impl Into<String>, entry: NewTimeEntry) -> Self {
Self {
issue: issue.into(),
intent: TimeIntent::Create(entry),
attribution: TimeAttribution::new(),
}
}
pub fn edit(
issue: impl Into<String>,
entry_id: impl Into<String>,
change: TimeEntryEdit,
reason: impl Into<String>,
) -> Self {
Self {
issue: issue.into(),
intent: TimeIntent::Edit {
entry_id: entry_id.into(),
change,
},
attribution: TimeAttribution::new().because(reason),
}
}
pub fn delete(
issue: impl Into<String>,
entry_id: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
issue: issue.into(),
intent: TimeIntent::Delete {
entry_id: entry_id.into(),
},
attribution: TimeAttribution::new().because(reason),
}
}
pub fn by(mut self, actor: impl Into<String>) -> Self {
self.attribution = self.attribution.by(actor);
self
}
pub fn at(mut self, timestamp: impl Into<String>) -> Self {
self.attribution = self.attribution.at(timestamp);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeMutationError {
UnknownEntry {
entry_id: String,
},
DuplicateEntryId {
entry_id: String,
},
PaddedEntryId {
entry_id: String,
},
EmptyEdit {
entry_id: String,
},
InvalidEntry {
entry_id: String,
problems: Vec<TimeEntryError>,
},
MissingActor,
InvalidTimestamp {
timestamp: String,
},
MissingReason {
action: &'static str,
},
UnsafeHistoryText {
field: &'static str,
hazard: &'static str,
},
}
impl TimeMutationError {
pub fn kind(&self) -> &'static str {
match self {
TimeMutationError::UnknownEntry { .. } => "unknown_entry",
TimeMutationError::DuplicateEntryId { .. } => "duplicate_entry_id",
TimeMutationError::PaddedEntryId { .. } => "padded_entry_id",
TimeMutationError::EmptyEdit { .. } => "empty_edit",
TimeMutationError::InvalidEntry { problems, .. } => problems
.first()
.map_or("malformed_entry", TimeEntryError::kind),
TimeMutationError::MissingActor => "missing_actor",
TimeMutationError::InvalidTimestamp { .. } => "invalid_timestamp",
TimeMutationError::MissingReason { .. } => "missing_reason",
TimeMutationError::UnsafeHistoryText { .. } => "unsafe_history_text",
}
}
pub fn field(&self) -> &'static str {
match self {
TimeMutationError::UnknownEntry { .. } | TimeMutationError::EmptyEdit { .. } => "entry",
TimeMutationError::DuplicateEntryId { .. }
| TimeMutationError::PaddedEntryId { .. } => "id",
TimeMutationError::InvalidEntry { problems, .. } => {
problems.first().map_or("entry", TimeEntryError::field)
}
TimeMutationError::MissingActor => "actor",
TimeMutationError::InvalidTimestamp { .. } => "timestamp",
TimeMutationError::MissingReason { .. } => "reason",
TimeMutationError::UnsafeHistoryText { field, .. } => field,
}
}
pub fn entry_id(&self) -> Option<&str> {
match self {
TimeMutationError::UnknownEntry { entry_id }
| TimeMutationError::DuplicateEntryId { entry_id }
| TimeMutationError::PaddedEntryId { entry_id }
| TimeMutationError::EmptyEdit { entry_id }
| TimeMutationError::InvalidEntry { entry_id, .. } => Some(entry_id.as_str()),
_ => None,
}
}
pub fn message(&self) -> String {
match self {
TimeMutationError::UnknownEntry { entry_id } => format!(
"no time entry `{entry_id}` on this issue; the current projection holds only the \
entries that were never deleted, and a deleted id is never reused"
),
TimeMutationError::DuplicateEntryId { entry_id } => format!(
"time entry `{entry_id}` already exists on this issue — in its current entries or \
in its History; ids are stable and never reused, so choose a new one"
),
TimeMutationError::PaddedEntryId { entry_id } => format!(
"time entry id {entry_id:?} has leading or trailing whitespace (the crate's rule \
for a name that becomes an identity); the stored id keeps its whitespace while a \
History event's id is recovered with it trimmed, so a padded spelling reads as \
the id it merely resembles and would let a deleted one back in"
),
TimeMutationError::EmptyEdit { entry_id } => format!(
"the edit of `{entry_id}` names no field (duration_minutes, date, author, note); \
there is nothing to change"
),
TimeMutationError::InvalidEntry { entry_id, problems } => {
let listed: Vec<String> = problems
.iter()
.map(|problem| format!("{} ({})", problem, problem.field()))
.collect();
format!("time entry `{entry_id}` is invalid: {}", listed.join("; "))
}
TimeMutationError::MissingActor => {
("a time mutation records who made it; the actor is blank, and an entry without \
attribution is not auditable")
.to_string()
}
TimeMutationError::InvalidTimestamp { timestamp } => format!(
"timestamp {timestamp:?} is not the crate's `YYYY-MM-DDTHH:MM:SSZ` instant shape; a \
mutation records the instant it happened rather than one that merely looks like \
evidence"
),
TimeMutationError::MissingReason { action } => format!(
"a time {action} requires `--reason`: the reason is what the audit trail keeps \
when the current value is replaced or removed"
),
TimeMutationError::UnsafeHistoryText { field, hazard } => format!(
"the {field} contains {hazard}; a `## History` entry is one line carrying the \
entry id, and text that breaks it would misread as an event nobody wrote"
),
}
}
}
impl fmt::Display for TimeMutationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.kind(), self.message())
}
}
impl std::error::Error for TimeMutationError {}
impl From<TimeMutationError> for TixError {
fn from(err: TimeMutationError) -> TixError {
TixError::InvalidInput(err.to_string())
}
}
fn check_entry_id(entry_id: &str) -> std::result::Result<(), TimeMutationError> {
check_history_text("id", entry_id, true)?;
if entry_id.trim() != entry_id {
return Err(TimeMutationError::PaddedEntryId {
entry_id: entry_id.to_string(),
});
}
Ok(())
}
fn check_history_text(
field: &'static str,
text: &str,
check_semicolon: bool,
) -> std::result::Result<(), TimeMutationError> {
if text.contains(['\n', '\r']) {
return Err(TimeMutationError::UnsafeHistoryText {
field,
hazard: "a line break",
});
}
if check_semicolon && text.contains(';') {
return Err(TimeMutationError::UnsafeHistoryText {
field,
hazard: "the `;` that separates the entry id from its values",
});
}
Ok(())
}
const DETAIL_PREFIX: &str = "entry ";
const DETAIL_FIELD_SEP: &str = "; ";
const VALUE_ARROW: &str = " -> ";
const REASON_KEY: &str = "; reason: ";
fn one_line(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for part in value
.split(['\n', '\r'])
.map(str::trim)
.filter(|part| !part.is_empty())
{
if !out.is_empty() {
out.push(' ');
}
out.push_str(part);
}
out
}
pub fn entry_summary(entry: &TimeEntry) -> String {
let mut out = match entry.minutes() {
Some(minutes) => format!("{minutes}m"),
None => "?m".to_string(),
};
if let Some(date) = entry
.date
.as_deref()
.map(one_line)
.filter(|d| !d.is_empty())
{
out.push_str(&format!(" on {date}"));
}
let author = one_line(&entry.author);
out.push_str(" by ");
out.push_str(if author.is_empty() { "?" } else { &author });
if let Some(note) = entry
.note
.as_deref()
.map(one_line)
.filter(|n| !n.is_empty())
{
out.push_str(": ");
out.push_str(¬e);
}
out
}
pub fn entry_id_in(detail: &str) -> Option<&str> {
let rest = detail.strip_prefix(DETAIL_PREFIX)?;
let (entry_id, _) = rest.split_once(DETAIL_FIELD_SEP)?;
let entry_id = entry_id.trim();
if entry_id.is_empty() {
None
} else {
Some(entry_id)
}
}
fn is_time_entry_kind(kind: HistoryEventKind) -> bool {
matches!(
kind,
HistoryEventKind::TimeEntryCreated
| HistoryEventKind::TimeEntryUpdated
| HistoryEventKind::TimeEntryDeleted
)
}
fn detail_for(
action: TimeAction,
entry_id: &str,
before: Option<&TimeEntry>,
after: Option<&TimeEntry>,
reason: Option<&str>,
) -> String {
let mut out = format!(
"{DETAIL_PREFIX}{entry_id}{DETAIL_FIELD_SEP}{}",
action.word()
);
match action {
TimeAction::Create => {
if let Some(after) = after {
out.push(' ');
out.push_str(&entry_summary(after));
}
}
TimeAction::Edit => {
let before = before.map_or_else(|| "?".to_string(), entry_summary);
let after = after.map_or_else(|| "?".to_string(), entry_summary);
out.push(' ');
out.push_str(&before);
out.push_str(VALUE_ARROW);
out.push_str(&after);
}
TimeAction::Delete => {
if let Some(before) = before {
out.push(' ');
out.push_str(&entry_summary(before));
}
}
}
if let Some(reason) = reason {
out.push_str(REASON_KEY);
out.push_str(reason);
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeIntentOutcome {
pub kind: HistoryEventKind,
pub entry_id: String,
pub before: Option<TimeEntry>,
pub after: Option<TimeEntry>,
pub event: Option<HistoryEvent>,
pub changed: bool,
}
pub fn intent(
issue: &Issue,
request: &TimeMutationRequest,
) -> std::result::Result<TimeIntentOutcome, TimeMutationError> {
let action = TimeAction::of(&request.intent);
let actor = request.attribution.actor()?;
let timestamp = request.attribution.timestamp()?;
let reason = request.attribution.reason(action)?;
match &request.intent {
TimeIntent::Create(new) => {
let entry = new.clone().resolve();
if let Some(problems) = validation_problems(&entry) {
return Err(TimeMutationError::InvalidEntry {
entry_id: entry.id.clone(),
problems,
});
}
check_entry_id(&entry.id)?;
check_current_id_is_free(issue, &entry.id)?;
let id = entry.id.clone();
let event = HistoryEvent::new(
timestamp,
actor,
action.kind(),
detail_for(action, &id, None, Some(&entry), reason.as_deref()),
);
Ok(TimeIntentOutcome {
kind: action.kind(),
entry_id: id,
before: None,
after: Some(entry),
event: Some(event),
changed: true,
})
}
TimeIntent::Edit { entry_id, change } => {
check_history_text("id", entry_id, true)?;
if change.is_empty() {
return Err(TimeMutationError::EmptyEdit {
entry_id: entry_id.clone(),
});
}
let before = current_entry(issue, entry_id)?.clone();
let after = change.apply_to(&before);
if let Some(problems) = validation_problems(&after) {
return Err(TimeMutationError::InvalidEntry {
entry_id: entry_id.clone(),
problems,
});
}
check_history_text("author", &after.author, false)?;
if let Some(note) = after.note.as_deref() {
check_history_text("note", note, false)?;
}
if after == before {
return Ok(TimeIntentOutcome {
kind: action.kind(),
entry_id: entry_id.clone(),
before: Some(before),
after: Some(after),
event: None,
changed: false,
});
}
let event = HistoryEvent::new(
timestamp,
actor,
action.kind(),
detail_for(
action,
entry_id,
Some(&before),
Some(&after),
reason.as_deref(),
),
);
Ok(TimeIntentOutcome {
kind: action.kind(),
entry_id: entry_id.clone(),
before: Some(before),
after: Some(after),
event: Some(event),
changed: true,
})
}
TimeIntent::Delete { entry_id } => {
check_history_text("id", entry_id, true)?;
let before = current_entry(issue, entry_id)?.clone();
let event = HistoryEvent::new(
timestamp,
actor,
action.kind(),
detail_for(action, entry_id, Some(&before), None, reason.as_deref()),
);
Ok(TimeIntentOutcome {
kind: action.kind(),
entry_id: entry_id.clone(),
before: Some(before),
after: None,
event: Some(event),
changed: true,
})
}
}
}
fn validation_problems(entry: &TimeEntry) -> Option<Vec<TimeEntryError>> {
let problems = entry.problems();
if problems.is_empty() {
None
} else {
Some(problems)
}
}
fn current_index(issue: &Issue, entry_id: &str) -> Option<usize> {
issue
.time_entries
.iter()
.position(|entry| entry.id == entry_id)
}
fn current_entry<'a>(
issue: &'a Issue,
entry_id: &str,
) -> std::result::Result<&'a TimeEntry, TimeMutationError> {
current_index(issue, entry_id)
.map(|index| &issue.time_entries[index])
.ok_or_else(|| TimeMutationError::UnknownEntry {
entry_id: entry_id.to_string(),
})
}
fn check_current_id_is_free(
issue: &Issue,
entry_id: &str,
) -> std::result::Result<(), TimeMutationError> {
let taken_now = current_index(issue, entry_id).is_some();
let taken_before = issue.history.events().into_iter().any(|event| {
is_time_entry_kind(event.kind) && entry_id_in(&event.detail) == Some(entry_id)
});
if taken_now || taken_before {
return Err(TimeMutationError::DuplicateEntryId {
entry_id: entry_id.to_string(),
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedTimeMutation {
pub issue: Issue,
pub outcome: TimeIntentOutcome,
}
impl AppliedTimeMutation {
pub fn event(&self) -> Option<&HistoryEvent> {
self.outcome.event.as_ref()
}
}
pub fn apply(store: &Store, request: &TimeMutationRequest) -> Result<AppliedTimeMutation> {
let mut issue = store.resolve_issue(&request.issue)?;
let outcome = intent(&issue, request).map_err(TixError::from)?;
if outcome.changed {
stage(&mut issue, &outcome);
issue.bump_updated();
issue.filename = store.transaction(|| store.update(&issue.filename, &issue))?;
}
Ok(AppliedTimeMutation { issue, outcome })
}
fn stage(issue: &mut Issue, outcome: &TimeIntentOutcome) {
match (&outcome.after, current_index(issue, &outcome.entry_id)) {
(Some(after), Some(index)) => issue.time_entries[index] = after.clone(),
(Some(after), None) => issue.time_entries.push(after.clone()),
(None, Some(index)) => {
issue.time_entries.remove(index);
}
(None, None) => {}
}
if let Some(event) = &outcome.event {
issue.history.append(event.clone());
}
}
pub fn new_entry_id() -> String {
format!("e-{}", crate::id::generate_issue_id())
}