#[allow(clippy::wildcard_imports)]
use super::*;
pub const HISTORY_HEADING: &str = "## History";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryEventKind {
RelationshipAdded,
RelationshipRemoved,
RelationshipRetargeted,
BlockedMoveAcknowledged,
ParentSet,
ParentChanged,
ParentCleared,
TransitionAccepted,
TriageAccepted,
TriageDeclined,
TriageDuplicated,
TriageDeferred,
Archived,
Restored,
HealthUpdated,
HealthOverridden,
ProjectAssociated,
ProjectDissociated,
EpicReferenced,
EpicDereferenced,
CycleClosed,
CycleExtended,
CycleSkipped,
CyclePaused,
CycleResumed,
CycleAdjusted,
CycleReopened,
TimeEntryCreated,
TimeEntryUpdated,
TimeEntryDeleted,
EstimateChanged,
CycleMembershipChanged,
}
impl HistoryEventKind {
pub fn as_str(self) -> &'static str {
match self {
HistoryEventKind::RelationshipAdded => "relationship_added",
HistoryEventKind::RelationshipRemoved => "relationship_removed",
HistoryEventKind::RelationshipRetargeted => "relationship_retargeted",
HistoryEventKind::BlockedMoveAcknowledged => "blocked_move_acknowledged",
HistoryEventKind::ParentSet => "parent_set",
HistoryEventKind::ParentChanged => "parent_changed",
HistoryEventKind::ParentCleared => "parent_cleared",
HistoryEventKind::TransitionAccepted => "transition_accepted",
HistoryEventKind::TriageAccepted => "triage_accepted",
HistoryEventKind::TriageDeclined => "triage_declined",
HistoryEventKind::TriageDuplicated => "triage_duplicated",
HistoryEventKind::TriageDeferred => "triage_deferred",
HistoryEventKind::Archived => "archived",
HistoryEventKind::Restored => "restored",
HistoryEventKind::HealthUpdated => "health_updated",
HistoryEventKind::HealthOverridden => "health_overridden",
HistoryEventKind::ProjectAssociated => "project_associated",
HistoryEventKind::ProjectDissociated => "project_dissociated",
HistoryEventKind::EpicReferenced => "epic_referenced",
HistoryEventKind::EpicDereferenced => "epic_dereferenced",
HistoryEventKind::CycleClosed => "cycle_closed",
HistoryEventKind::CycleExtended => "cycle_extended",
HistoryEventKind::CycleSkipped => "cycle_skipped",
HistoryEventKind::CyclePaused => "cycle_paused",
HistoryEventKind::CycleResumed => "cycle_resumed",
HistoryEventKind::CycleAdjusted => "cycle_adjusted",
HistoryEventKind::CycleReopened => "cycle_reopened",
HistoryEventKind::TimeEntryCreated => "time_entry_created",
HistoryEventKind::TimeEntryUpdated => "time_entry_updated",
HistoryEventKind::TimeEntryDeleted => "time_entry_deleted",
HistoryEventKind::EstimateChanged => "estimate_changed",
HistoryEventKind::CycleMembershipChanged => "cycle_membership_changed",
}
}
#[must_use]
pub fn is_triage_decision(self) -> bool {
matches!(
self,
HistoryEventKind::TriageAccepted
| HistoryEventKind::TriageDeclined
| HistoryEventKind::TriageDuplicated
| HistoryEventKind::TriageDeferred
)
}
}
impl fmt::Display for HistoryEventKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for HistoryEventKind {
type Err = TixError;
fn from_str(s: &str) -> Result<Self> {
match s {
"relationship_added" => Ok(HistoryEventKind::RelationshipAdded),
"relationship_removed" => Ok(HistoryEventKind::RelationshipRemoved),
"relationship_retargeted" => Ok(HistoryEventKind::RelationshipRetargeted),
"blocked_move_acknowledged" => Ok(HistoryEventKind::BlockedMoveAcknowledged),
"parent_set" => Ok(HistoryEventKind::ParentSet),
"parent_changed" => Ok(HistoryEventKind::ParentChanged),
"parent_cleared" => Ok(HistoryEventKind::ParentCleared),
"transition_accepted" => Ok(HistoryEventKind::TransitionAccepted),
"triage_accepted" => Ok(HistoryEventKind::TriageAccepted),
"triage_declined" => Ok(HistoryEventKind::TriageDeclined),
"triage_duplicated" => Ok(HistoryEventKind::TriageDuplicated),
"triage_deferred" => Ok(HistoryEventKind::TriageDeferred),
"archived" => Ok(HistoryEventKind::Archived),
"restored" => Ok(HistoryEventKind::Restored),
"health_updated" => Ok(HistoryEventKind::HealthUpdated),
"health_overridden" => Ok(HistoryEventKind::HealthOverridden),
"project_associated" => Ok(HistoryEventKind::ProjectAssociated),
"project_dissociated" => Ok(HistoryEventKind::ProjectDissociated),
"epic_referenced" => Ok(HistoryEventKind::EpicReferenced),
"epic_dereferenced" => Ok(HistoryEventKind::EpicDereferenced),
"cycle_closed" => Ok(HistoryEventKind::CycleClosed),
"cycle_extended" => Ok(HistoryEventKind::CycleExtended),
"cycle_skipped" => Ok(HistoryEventKind::CycleSkipped),
"cycle_paused" => Ok(HistoryEventKind::CyclePaused),
"cycle_resumed" => Ok(HistoryEventKind::CycleResumed),
"cycle_adjusted" => Ok(HistoryEventKind::CycleAdjusted),
"cycle_reopened" => Ok(HistoryEventKind::CycleReopened),
"time_entry_created" => Ok(HistoryEventKind::TimeEntryCreated),
"time_entry_updated" => Ok(HistoryEventKind::TimeEntryUpdated),
"time_entry_deleted" => Ok(HistoryEventKind::TimeEntryDeleted),
"estimate_changed" => Ok(HistoryEventKind::EstimateChanged),
"cycle_membership_changed" => Ok(HistoryEventKind::CycleMembershipChanged),
other => Err(TixError::Parse(format!(
"unknown History event kind `{other}`"
))),
}
}
}
impl HistoryEventKind {
pub fn from_parent(kind: crate::hierarchy::ParentEventKind) -> Self {
match kind {
crate::hierarchy::ParentEventKind::ParentSet => HistoryEventKind::ParentSet,
crate::hierarchy::ParentEventKind::ParentChanged => HistoryEventKind::ParentChanged,
crate::hierarchy::ParentEventKind::ParentCleared => HistoryEventKind::ParentCleared,
}
}
#[must_use]
pub fn from_project(kind: crate::projects::ProjectEventKind) -> Self {
match kind {
crate::projects::ProjectEventKind::ProjectAssociated => {
HistoryEventKind::ProjectAssociated
}
crate::projects::ProjectEventKind::ProjectDissociated => {
HistoryEventKind::ProjectDissociated
}
}
}
#[must_use]
pub fn from_epic(kind: crate::epics::EpicEventKind) -> Self {
match kind {
crate::epics::EpicEventKind::EpicReferenced => HistoryEventKind::EpicReferenced,
crate::epics::EpicEventKind::EpicDereferenced => HistoryEventKind::EpicDereferenced,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionDecision {
pub timestamp: String,
pub actor: String,
pub from: String,
pub to: String,
pub from_category: String,
pub to_category: String,
pub override_reason: Option<String>,
}
impl TransitionDecision {
pub fn new(
timestamp: impl Into<String>,
actor: impl Into<String>,
from: impl Into<String>,
to: impl Into<String>,
from_category: impl Into<String>,
to_category: impl Into<String>,
override_reason: Option<String>,
) -> Self {
Self {
timestamp: timestamp.into(),
actor: actor.into(),
from: from.into(),
to: to.into(),
from_category: from_category.into(),
to_category: to_category.into(),
override_reason,
}
}
pub fn detail(&self) -> String {
let mut out = format!(
"{from}{arrow}{to} ({fc}{arrow}{tc})",
from = self.from,
to = self.to,
fc = self.from_category,
tc = self.to_category,
arrow = DETAIL_ARROW,
);
if let Some(reason) = self.override_reason.as_deref().map(str::trim) {
if !reason.is_empty() {
out.push_str(OVERRIDE_MARKER.trim_start_matches(')'));
out.push_str(reason);
}
}
out
}
pub fn from_event(event: &HistoryEvent) -> Result<Self> {
if event.kind != HistoryEventKind::TransitionAccepted {
return Err(TixError::Parse(format!(
"History entry is a `{}`, not a `transition_accepted`",
event.kind
)));
}
let malformed =
|what: &str| TixError::Parse(format!("transition record {what}: `{}`", event.detail));
let (head, override_reason) = match event.detail.split_once(OVERRIDE_MARKER) {
Some((head, reason)) => (head, Some(reason.trim().to_string())),
None => (event.detail.as_str(), None),
};
let head = match &override_reason {
Some(_) => format!("{head})"),
None => head.to_string(),
};
let head = head.as_str();
let (from, rest) = head
.split_once(DETAIL_ARROW)
.ok_or_else(|| malformed("has no `from -> to`"))?;
let rest = rest
.strip_suffix(')')
.ok_or_else(|| malformed("has no closing `)` after the categories"))?;
let (to, categories) = rest
.split_once(CATEGORY_OPEN)
.ok_or_else(|| malformed("has no ` (` before the categories"))?;
let (from_category, to_category) = categories
.split_once(DETAIL_ARROW)
.ok_or_else(|| malformed("has no `from_category -> to_category`"))?;
for (name, value) in [
("from", from),
("to", to),
("from_category", from_category),
("to_category", to_category),
] {
if value.trim().is_empty() {
return Err(malformed(name));
}
}
Ok(Self {
timestamp: event.timestamp.clone(),
actor: event.actor.clone(),
from: from.to_string(),
to: to.to_string(),
from_category: from_category.to_string(),
to_category: to_category.to_string(),
override_reason,
})
}
pub fn to_event(&self) -> HistoryEvent {
HistoryEvent::new(
self.timestamp.clone(),
self.actor.clone(),
HistoryEventKind::TransitionAccepted,
self.detail(),
)
}
pub fn is_no_op(&self) -> bool {
self.from == self.to
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistoryEvent {
pub timestamp: String,
pub actor: String,
pub kind: HistoryEventKind,
pub detail: String,
}
impl HistoryEvent {
pub fn new(
timestamp: impl Into<String>,
actor: impl Into<String>,
kind: HistoryEventKind,
detail: impl Into<String>,
) -> Self {
Self {
timestamp: timestamp.into(),
actor: actor.into(),
kind,
detail: detail.into(),
}
}
pub(super) fn render(&self) -> String {
format!(
"- {t} {h} {a} {h} {k} {h} {d}",
t = self.timestamp.trim(),
h = HISTORY_SEP,
a = self.actor.trim(),
k = self.kind.as_str(),
d = self.detail
)
}
pub(super) fn parse(line: &str) -> Result<Self> {
let item = line
.trim()
.strip_prefix("- ")
.ok_or_else(|| TixError::Parse(format!("History entry is not a list item: {line}")))?;
let parts: Vec<&str> = item.splitn(4, HISTORY_SEP).collect();
let missing =
|what: &str| TixError::Parse(format!("History entry is missing `{what}`: {line}"));
if parts.len() < 3 {
return Err(missing("a field separator"));
}
let timestamp = parts[0].trim();
if timestamp.is_empty() {
return Err(missing("timestamp"));
}
let actor = parts[1].trim().to_string();
let kind_text = parts[2].trim();
if kind_text.is_empty() {
return Err(missing("kind"));
}
let kind = kind_text.parse::<HistoryEventKind>()?;
let detail = parts.get(3).copied().unwrap_or_default().trim().to_string();
Ok(Self {
timestamp: timestamp.to_string(),
actor,
kind,
detail,
})
}
}
pub fn looks_like_history_entry(line: &str) -> bool {
let item = line.trim();
let Some(rest) = item.strip_prefix("- ") else {
return false;
};
rest.contains(HISTORY_SEP)
}
pub fn looks_like_an_unrecognised_entry(line: &str) -> bool {
let Some(rest) = strip_list_marker(line) else {
return false;
};
if rest.contains(HISTORY_SEP) {
return false; }
entry_shaped_by_ascii_hyphens(rest, STRICT_TAIL_FIELDS)
}
pub fn is_history_heading(line: &str) -> bool {
line.trim_end_matches([' ', '\t']) == HISTORY_HEADING
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HistoryEntry {
Event(HistoryEvent),
Unparsed(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct History {
pub entries: Vec<HistoryEntry>,
}
impl History {
pub fn new() -> Self {
Self::default()
}
pub fn append(&mut self, event: HistoryEvent) {
self.entries.push(HistoryEntry::Event(event));
}
pub fn append_now(&mut self, actor: &str, kind: HistoryEventKind, detail: &str) {
self.append(HistoryEvent::new(utc_now_rfc3339(), actor, kind, detail));
}
pub fn events(&self) -> Vec<&HistoryEvent> {
self.entries
.iter()
.filter_map(|e| match e {
HistoryEntry::Event(event) => Some(event),
HistoryEntry::Unparsed(_) => None,
})
.collect()
}
pub fn unparsed(&self) -> Vec<&str> {
self.entries
.iter()
.filter_map(|e| match e {
HistoryEntry::Event(_) => None,
HistoryEntry::Unparsed(line) => Some(line.as_str()),
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn render(&self) -> String {
if self.is_empty() {
return String::new();
}
let mut out = String::from(HISTORY_HEADING);
out.push_str("\n\n");
for entry in &self.entries {
match entry {
HistoryEntry::Event(event) => {
out.push_str(&event.render());
out.push('\n');
}
HistoryEntry::Unparsed(line) => {
out.push_str(line);
out.push('\n');
}
}
}
out.push('\n');
out
}
pub fn parse_front(body: &str) -> (Self, String) {
let lead = body.len() - body.trim_start_matches('\n').len();
let trimmed = &body[lead..];
let Some(rest) = trimmed.strip_prefix(HISTORY_HEADING) else {
return (History::new(), body.to_string());
};
let rest = rest.trim_start_matches([' ', '\t']);
let Some(after_heading) = rest
.strip_prefix("\r\n")
.or_else(|| rest.strip_prefix('\n'))
else {
return (History::new(), body.to_string());
};
let after_heading = after_heading.trim_start_matches(['\r', '\n']);
let mut history = History::new();
let mut offset = 0usize;
let mut saw_rejected_list_item = false;
for line in after_heading.split_inclusive('\n') {
let raw_line = line;
let item = raw_line.trim();
if item.is_empty() {
offset += line.len();
continue;
}
if !looks_like_history_entry(item) {
let following: Vec<&str> = after_heading[offset + line.len()..]
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
let plausible_entry = is_entry_attempt(item);
let after_all_entries = following.iter().all(|l| looks_like_history_entry(l));
let keep = if following.is_empty() {
is_list_item(item)
} else {
plausible_entry || after_all_entries
};
if !keep {
saw_rejected_list_item |= is_list_item(item);
break;
}
history.entries.push(HistoryEntry::Unparsed(
raw_line.trim_end_matches('\n').to_string(),
));
offset += line.len();
continue;
}
match HistoryEvent::parse(item) {
Ok(event) => history.entries.push(HistoryEntry::Event(event)),
Err(_) => history.entries.push(HistoryEntry::Unparsed(
raw_line.trim_end_matches('\n').to_string(),
)),
}
offset += line.len();
}
if history.entries.is_empty() && saw_rejected_list_item {
return (history, body.to_string());
}
(history, after_heading[offset..].to_string())
}
}