use crate::error::{Result, TixError};
use crate::model::{HistoryEvent, HistoryEventKind, Issue};
const ARROW: &str = " -> ";
const TARGET_MARKER: &str = "; target: #";
const SOURCE_MARKER: &str = "; source: ";
const REASON_MARKER: &str = "; reason: ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageDecisionRecord {
pub timestamp: String,
pub actor: String,
pub kind: HistoryEventKind,
pub prior_state: String,
pub result: String,
pub reason: Option<String>,
pub target_issue: Option<u32>,
pub source: Option<String>,
}
impl TriageDecisionRecord {
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn new(
timestamp: impl Into<String>,
actor: impl Into<String>,
kind: HistoryEventKind,
prior_state: impl Into<String>,
result: impl Into<String>,
reason: Option<String>,
target_issue: Option<u32>,
source: Option<String>,
) -> Self {
Self {
timestamp: timestamp.into(),
actor: actor.into(),
kind,
prior_state: prior_state.into(),
result: result.into(),
reason: reason.filter(|reason| !reason.trim().is_empty()),
target_issue,
source: source.filter(|source| !source.trim().is_empty()),
}
}
#[must_use]
pub fn detail(&self) -> String {
let mut out = format!("{}{ARROW}{}", self.prior_state, self.result);
if let Some(target) = self.target_issue {
out.push_str(&format!("{TARGET_MARKER}{target}"));
}
if let Some(source) = self.source.as_deref() {
if !source.trim().is_empty() {
out.push_str(&format!("{SOURCE_MARKER}{source}"));
}
}
if let Some(reason) = self.reason.as_deref() {
if !reason.trim().is_empty() {
out.push_str(&format!("{REASON_MARKER}{reason}"));
}
}
out
}
#[must_use]
pub fn to_event(&self) -> HistoryEvent {
HistoryEvent::new(&self.timestamp, &self.actor, self.kind, self.detail())
}
pub fn from_event(event: &HistoryEvent) -> Result<Self> {
if !event.kind.is_triage_decision() {
return Err(TixError::Parse(format!(
"History entry is a `{}`, not a triage decision",
event.kind
)));
}
let (reason, rest) = split_marker(&event.detail, REASON_MARKER);
let (source, rest) = split_marker(rest, SOURCE_MARKER);
let (target, head) = split_marker(rest, TARGET_MARKER);
let (prior_state, result) = head.split_once(ARROW).ok_or_else(|| {
TixError::Parse(format!(
"triage decision detail `{head}` has no `{ARROW}` separator"
))
})?;
if prior_state.trim().is_empty() || result.trim().is_empty() {
return Err(TixError::Parse(format!(
"triage decision detail `{}` has an empty side of the separator",
event.detail
)));
}
let target_issue = match target {
Some(raw) => Some(raw.parse::<u32>().map_err(|_| {
TixError::Parse(format!(
"triage decision target `#{raw}` is not an issue number"
))
})?),
None => None,
};
Ok(Self {
timestamp: event.timestamp.clone(),
actor: event.actor.clone(),
kind: event.kind,
prior_state: prior_state.to_string(),
result: result.to_string(),
reason: reason.filter(|r| !r.trim().is_empty()),
target_issue,
source: source.filter(|s| !s.trim().is_empty()),
})
}
#[must_use]
pub fn is_same_decision(&self, kind: HistoryEventKind, reason: Option<&str>) -> bool {
let asked = reason.filter(|reason| !reason.trim().is_empty());
self.kind == kind && self.reason.as_deref() == asked
}
}
fn split_marker<'a>(input: &'a str, marker: &str) -> (Option<String>, &'a str) {
match input.split_once(marker) {
Some((head, value)) => (Some(value.to_string()), head),
None => (None, input),
}
}
#[must_use]
pub fn has_triage_decision(issue: &Issue) -> bool {
issue
.history
.events()
.iter()
.any(|event| event.kind.is_triage_decision())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LastDecision {
None,
Readable(TriageDecisionRecord),
Unreadable,
}
#[must_use]
pub fn last_decision_state(issue: &Issue) -> LastDecision {
let Some(event) = issue
.history
.events()
.into_iter()
.rev()
.find(|event| event.kind.is_triage_decision())
else {
return LastDecision::None;
};
match TriageDecisionRecord::from_event(event) {
Ok(record) => LastDecision::Readable(record),
Err(_) => LastDecision::Unreadable,
}
}
#[must_use]
pub fn last_decision(issue: &Issue) -> Option<TriageDecisionRecord> {
match last_decision_state(issue) {
LastDecision::Readable(record) => Some(record),
LastDecision::None | LastDecision::Unreadable => None,
}
}