tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The triage decision record: one decision, one append-only `History` event.
//!
//! Mission `triage-inbox-review-flow-01M3C600`, WP03 (T016). This is the
//! adapter between [`crate::triage::decision`]'s typed request and the #169
//! History grammar — data-model §4's [`TriageDecisionRecord`] is exactly this
//! struct, and the four [`HistoryEventKind`] spellings are the contract's
//! `triage_accepted` / `triage_declined` / `triage_duplicated` /
//! `triage_deferred`.
//!
//! There is no second log. A decision becomes a line in the same `## History`
//! section every other mutation writes, parsed by the same
//! [`crate::model::History`] parser, and this module only owns the *detail*
//! grammar behind the kind field.
//!
//! The grammar mirrors [`crate::model::TransitionDecision`] for the reason that
//! one exists: a `detail` a reader can only interpret as prose is unusable to
//! the code that has to answer "what did this decision do?" — the inbox's
//! `last_decision` summary (data-model §5), and any later lint that compares a
//! file's state against its own record. So the detail is a fixed shape:
//!
//! ```text
//! <prior> -> <result>[; target: #<n>][; source: <origin>][; reason: <text>]
//! ```
//!
//! e.g. `triage -> in progress; reason: confirmed scope`, or
//! `triage -> duplicates #2; target: #2; source: import; reason: same repro`.
//!
//! ## Why the fields are peeled from the right
//!
//! `reason` is free text and may itself contain `" -> "`, `"; source: "` or
//! anything else, so it is rendered **last** and parsed **first** at its marker:
//! the first occurrence of [`REASON_MARKER`] in a rendered detail is always the
//! real one, because everything before it is structured. The structured fields
//! are then peeled in the same reverse order (`source`, `target`), and the head
//! is split on the first [`ARROW`].
//!
//! ## Known limit: a marker inside a structured field
//!
//! A column literally named `a; reason: b`, or an entry source containing
//! `"; target: #"`, would render into a detail that parses back into different
//! fields. This is the same delimiter-without-escaping limit
//! [`crate::model::TransitionDecision`] documents and pins, and it is inherent
//! rather than accidental: escaping would put backslashes into a line a human
//! reads. The fields that can contain a marker are the two free-text ones, and
//! one of them (`reason`) is already placed where it cannot be mistaken.

use crate::error::{Result, TixError};
use crate::model::{HistoryEvent, HistoryEventKind, Issue};

/// Separates `prior` from `result` in the detail head.
const ARROW: &str = " -> ";

/// Introduces the duplicate target number. The `#` is part of the marker so a
/// value that is not a number is a parse failure rather than a partial record.
const TARGET_MARKER: &str = "; target: #";

/// Introduces the entry/defer source.
const SOURCE_MARKER: &str = "; source: ";

/// Introduces the reason. Always the last field rendered, always the first
/// peeled.
const REASON_MARKER: &str = "; reason: ";

/// The append-only History payload of one applied triage decision (data-model
/// §4).
///
/// Built only for decisions that were **applied**: a refusal and a no-op both
/// produce no record and therefore no event (contract `decision-history.md`).
/// Every field listed in data-model §4 is present, and `kind` is constrained by
/// the type to one of the four triage kinds — a record whose kind is, say,
/// [`HistoryEventKind::TransitionAccepted`] cannot be constructed through
/// [`TriageDecisionRecord::new`], and cannot be read back by
/// [`TriageDecisionRecord::from_event`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageDecisionRecord {
    /// When the decision was made, RFC 3339. Supplied by the caller so a test
    /// (and a batch replay) gets a deterministic event time.
    pub timestamp: String,
    /// Who decided. Required by the service; never silently anonymous.
    pub actor: String,
    /// Which of the four decision kinds this is.
    pub kind: HistoryEventKind,
    /// The column the issue was in before the decision.
    pub prior_state: String,
    /// The column the issue is in after it, or the typed relation for a
    /// duplicate decision.
    pub result: String,
    /// The recorded reason, when one was supplied. `None` for an accept that
    /// gave none — data-model §3 requires the reason for decline, duplicate and
    /// defer, and records accept's "when supplied".
    pub reason: Option<String>,
    /// The duplicate target, for a duplicate decision only.
    pub target_issue: Option<u32>,
    /// The entry context after the decision: `defer` after a defer, otherwise
    /// whatever origin the issue still carries (and `None` once it left the
    /// inbox, which clears its entry).
    pub source: Option<String>,
}

impl TriageDecisionRecord {
    /// The record of one applied decision.
    ///
    /// The History kind is taken directly rather than a decision kind, so that
    /// the round trip `record -> event -> record` is a pure function of these
    /// fields with nothing left to translate afterwards; the decision-to-history
    /// mapping lives in [`crate::triage::decision`], which is the only caller.
    ///
    /// Passing a non-triage `kind` is not rejected here — it cannot be expressed
    /// by that caller, and [`Self::from_event`] refuses to read one back, so a
    /// record with a foreign kind can never round trip.
    ///
    /// Eight arguments, over `clippy`'s seven: each one is a row of
    /// data-model §4's field table, and an options struct would only move the
    /// same list somewhere it could be defaulted.
    ///
    /// Blank `reason` and `source` are dropped here rather than at each caller:
    /// this is the one place a record can be born outside [`Self::from_event`],
    /// so making the constructor own "a record never holds an empty optional"
    /// means [`Self::detail`] and [`Self::from_event`] never have to disagree
    /// about what a record with no reason is.
    #[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()),
        }
    }

    /// Render to the single `detail` string the History line carries.
    ///
    /// Blank optional fields are **omitted rather than rendered empty**, so
    /// `detail()` and [`Self::from_event`] agree that a decision with no reason
    /// has `reason == None` instead of `Some("")`. A marker with an empty value
    /// would also make a hand-written line indistinguishable from a rendered
    /// one that lost its text.
    #[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
    }

    /// The event to append: this record, rendered into the shared grammar.
    #[must_use]
    pub fn to_event(&self) -> HistoryEvent {
        HistoryEvent::new(&self.timestamp, &self.actor, self.kind, self.detail())
    }

    /// Recover a record from an event.
    ///
    /// `Err` when the event is not one of the four triage kinds, or its `detail`
    /// does not match the grammar — never a partially-filled struct, so a caller
    /// can treat `Ok` as "this line records a decision". Returning `Err` rather
    /// than `None` keeps the kind mistake visible: a `parent_set` handed to a
    /// decision reader is a programming error, while a malformed detail is a
    /// hand-edited line. Both are the parse failure this log already reports for
    /// an unknown kind.
    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
            )));
        }

        // Peel in the reverse of render order; see the module docs.
        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()),
        })
    }

    /// Whether this record asks for exactly the decision `kind` with exactly
    /// `reason`.
    ///
    /// The idempotence comparison (T016): a repeat is a no-op only when both the
    /// decision *and* its recorded reason match, so re-declining with a
    /// different reason is a new decision and appends — which is what quickstart
    /// §7's "a changed decision appends a new event" means. The comparison is
    /// text-exact once a blank reason is read as *no* reason on both sides: a
    /// record never stores a blank one (see [`Self::detail`]), so asking for one
    /// must not read as asking for something else.
    #[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
    }
}

/// Split an input at its first `marker`: `(the value after it, everything before
/// it)`, or `(None, input)` when the marker is absent.
///
/// The value comes back first because every marker is rendered *after* the
/// field it introduces, so peeling one leaves the fields before it — which is
/// why the caller peels in reverse render order. The value keeps its own text: a
/// reason may contain anything, and blankness is the *record's* rule to apply
/// (it omits blank fields), not this one's.
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),
    }
}

/// Whether this issue's History records a triage decision at all.
///
/// Answered from the **kind**, not from a successful parse: a triage line whose
/// detail was hand-edited into an unreadable shape still proves a decision
/// happened, and `not_in_triage` vs `already_decided` must not turn on whether
/// the detail survived editing. Idempotence, which needs the *values*, uses
/// [`last_decision`] instead and treats an unreadable record as "not the same
/// decision".
#[must_use]
pub fn has_triage_decision(issue: &Issue) -> bool {
    issue
        .history
        .events()
        .iter()
        .any(|event| event.kind.is_triage_decision())
}

/// What the newest triage `## History` line on an issue is.
///
/// The **display** answer, told apart where [`last_decision`] deliberately cannot:
/// a triage line that fails to re-parse is [`Self::Unreadable`], not "no decision".
/// "Nobody has decided this" and "this file carries a decision I cannot read" are
/// different answers to a maintainer, and the second is the diagnostic the
/// escalation rules want surfaced rather than guessed (tix #1494).
///
/// The idempotence comparison keeps using [`last_decision`]: for a comparison an
/// unreadable record really does mean "not the same decision", so a reader that
/// wants to *show* the state wants this, and a caller that wants to *compare*
/// wants that.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LastDecision {
    /// No triage decision recorded at all.
    None,
    /// The newest triage decision, parsed.
    Readable(TriageDecisionRecord),
    /// A triage decision line exists but its detail does not parse.
    Unreadable,
}

/// The newest triage `## History` line, with an explicit unreadable state.
///
/// The parse error itself is not carried: every reader renders one
/// sentence (`unreadable`), and the parser's own text is `TriageDecisionRecord`'s
/// to say when a caller asks it directly.
#[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,
    }
}

/// The most recent triage decision recorded on `issue`, parsed.
///
/// `None` when the issue has no triage event, or when its most recent one is
/// unreadable — both mean "no decision to compare against", which is exactly
/// what the idempotence check asks. Older, readable decisions are deliberately
/// **not** used as a substitute: the newest one is the one the file currently
/// claims.
///
/// This is the **comparison** half. A reader that has to *show* what happened
/// uses [`last_decision_state`] instead, so an unreadable newest line is not
/// rendered as "no decision" (tix #1494).
#[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,
    }
}