tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Triage inbox: configuration, entry metadata, and the inbox query.
//!
//! Mission `triage-inbox-review-flow-01M3C600`. Triage is a **view over normal
//! statuses**, not a second workflow: the inbox is whichever columns the optional
//! `[triage]` section names, membership and category come from the #183 service,
//! and every decision is an append-only `History` event on the issue file
//! (C-001, C-002).
//!
//! WP01 owns two things:
//!
//! - [`config`] — the section's schema and its validation through #183;
//! - the two optional frontmatter keys that make the view answerable, which is
//!   what this file holds: when an issue entered, and from where.
//!
//! WP02 adds [`inbox`] — the read-only query over those same two things: scope,
//! entries, age, and counts, all derived from one issue snapshot.
//!
//! WP03 adds [`decision`] — the four decisions and their refusals — and
//! [`history`], the event intent that records one of them. Both stay inside the
//! same three things: normal statuses, normal relationships, and the existing
//! `## History` section.
//!
//! WP07 adds [`import_policy`] — where a *remote* import places a newly
//! created file, so an import cannot enter the board through the side door.
//! It is a policy over the same columns, not a status rule of its own.
//!
//! There is no queue store, no row, and no second issue set anywhere in this
//! module. Everything downstream derives from an issue snapshot.

pub mod batch;
pub mod config;
pub mod decision;
pub mod history;
pub mod import_policy;
pub mod inbox;

pub use batch::TriagePlan;
pub use config::TriageConfig;
pub use decision::{
    DecisionKind, DecisionOutcome, DecisionService, DuplicateTarget, Refusal, TriageDecision,
};
pub use history::TriageDecisionRecord;

use crate::model::Issue;

/// The entry timestamp to compute queue age from, with its one documented
/// fallback (T003, FR-005).
///
/// Research R4 rejects `created` (it predates the queue) and `updated` (it moves
/// on every edit) as age proxies, so an issue that recorded no entry time has an
/// **unknown** age — and this enum says so rather than substituting a date that
/// measures something else (data-model §2: "must not silently invent an exact
/// age").
///
/// The shape is the point. Returning `None` from an `Option<&str>` would let a
/// caller `unwrap_or` a borrowed `updated` without appearing to make a decision;
/// [`EntryTime::Unknown`] has to be matched before any number can be produced, so
/// the fallback is written down at every call site or the code does not compile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryTime<'a> {
    /// The issue's `entered_at`, verbatim.
    Recorded(&'a str),
    /// The issue has no `entered_at`. Report the age as unknown.
    Unknown,
}

/// The timestamp queue age is derived from.
///
/// The **only** supported source of an inbox age; see [`EntryTime`] for why the
/// fallback is a variant rather than a date.
#[must_use]
pub fn entry_time(issue: &Issue) -> EntryTime<'_> {
    match issue.entered_at.as_deref() {
        Some(at) => EntryTime::Recorded(at),
        None => EntryTime::Unknown,
    }
}

/// The origin of the issue's current entry, or `None` when it recorded none.
#[must_use]
pub fn entry_source(issue: &Issue) -> Option<&str> {
    issue.source.as_deref()
}

/// Stamp an issue as having entered triage at `at`, with an optional `source`.
///
/// Called on **enter and re-enter** (data-model §2, T003): a defer refreshes
/// [`Issue::entered_at`], so the queue measures the wait since the issue most
/// recently joined (data-model §5). The value is therefore overwritten, never
/// merged — keeping the oldest stamp would answer "how long has this been in the
/// inbox" with "how long has this existed".
///
/// `source` takes exactly what the caller passes, `None` included. A source
/// describes *this* entry, so carrying a previous entry's origin forward would
/// credit an entry to something that did not cause it.
///
/// Frontmatter only. The `History` event that records the decision belongs to the
/// decision service (WP03); WP01 owns the metadata and appends nothing to
/// [`crate::model::History`].
pub fn record_entry(issue: &mut Issue, at: &str, source: Option<&str>) {
    issue.entered_at = Some(at.to_string());
    issue.source = source.map(str::to_string);
}

/// Stamp an issue that **lands in the inbox**, and leave every other one alone.
///
/// The one rule every entry path shares — a create (CLI, TUI form, MCP), an import, a
/// defer — and the question it answers is the *landing column*, not which flag or policy
/// produced it: an issue in the inbox has the stamp its age is measured from, and an
/// issue that never entered the queue gains nothing.
///
/// One function rather than an `if` per surface, because that is exactly how the others
/// drifted: the CLI's create core had a copy and the TUI's form did not, and the
/// import's copy tested `Mapping::routed` — a *policy* notion — so an import under
/// `--triage-policy remote` (or a default whose remote-mapped column happens to be the
/// entry column) landed in the queue unstamped, and the queue reported `unknown` for an
/// issue it was listing.
///
/// `source` is the origin data-model §2 documents for this path (`new`, `import`,
/// `moved`, `defer`).
///
/// # Errors
///
/// Propagates a `[triage]` section the board cannot satisfy — the same refusal
/// [`inbox::scope`] gives every other triage path.
pub fn record_entry_if_inbox(
    config: &crate::config::Config,
    issue: &mut Issue,
    source: &str,
) -> crate::error::Result<()> {
    if let Some(scope) = inbox::scope(config)? {
        let landing = issue.status.display();
        if landing == scope.entry_status || landing == scope.deferred_status {
            record_entry(issue, &crate::model::utc_now_rfc3339(), Some(source));
        }
    }
    Ok(())
}

/// Stamp a **status change** that landed in the inbox, reporting whether it wrote
/// a stamp.
///
/// The fourth entry path (tix #1489): a `tix move` or `tix edit --status` into an
/// inbox column is an entry like any other — the issue is now in the queue, and the
/// queue's age column needs a stamp to measure the wait from. The origin word is
/// `moved`, added to data-model §2's vocabulary for exactly this: `new` would be
/// false for an issue that has existed for a year, and the design's rule is that a
/// missing answer is `unknown` rather than an invented one.
///
/// Must be called **after** the status change is applied, since the landing column
/// is what decides whether the stamp is due — and only when the change actually
/// wrote something, so a same-column no-op does not restart the wait.
///
/// Returns `true` when the entry metadata changed, which is the caller's signal
/// that the stamp needs its own write (the status seam already wrote the move).
///
/// # Errors
///
/// Propagates the same `[triage]` refusal [`record_entry_if_inbox`] does.
pub fn record_move_entry(
    config: &crate::config::Config,
    issue: &mut Issue,
) -> crate::error::Result<bool> {
    let before = (issue.entered_at.clone(), issue.source.clone());
    record_entry_if_inbox(config, issue, "moved")?;
    Ok((issue.entered_at.clone(), issue.source.clone()) != before)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Issue, Status};

    /// Every key the model always writes, plus nothing triage-specific: the shape
    /// of every issue file that exists today (research R1).
    const LEGACY: &str = "---\nnumber: 1\ntitle: Legacy\nstatus: todo\nlabels: []\n\
                         priority: medium\nfiles: []\ncreated: 2026-01-01\n\
                         updated: 2026-01-01\n---\n\nBody text.";

    fn legacy_issue() -> Issue {
        Issue::parse(LEGACY).expect("the baseline fixture parses")
    }

    // Every WP01 test is named `triage_config_*`, including these, because the
    // work package's gate is `cargo test triage_config` and it must select the
    // whole work package — entry metadata included.

    #[test]
    fn triage_config_metadata_is_absent_on_a_legacy_issue_and_stays_that_way() {
        let issue = legacy_issue();
        assert_eq!(issue.entered_at, None, "no queue, no stamp");
        assert_eq!(issue.source, None, "and no origin");

        let out = issue.to_file_contents();
        assert!(
            !out.contains("entered_at") && !out.contains("source:"),
            "an issue that never entered triage must not gain a key (C-001):\n{out}"
        );
        assert_eq!(out, LEGACY, "so a legacy file round-trips byte-identically");
    }

    #[test]
    fn triage_config_metadata_round_trips_through_frontmatter() {
        let mut issue = legacy_issue();
        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("import"));

        let out = issue.to_file_contents();
        assert!(
            out.contains("entered_at: 2026-09-30T09:12:44Z"),
            "the stamp reaches the file:\n{out}"
        );
        assert!(
            out.contains("source: import"),
            "and so does the origin:\n{out}"
        );

        let reloaded = Issue::parse(&out).expect("re-parses");
        assert_eq!(
            reloaded.entered_at.as_deref(),
            Some("2026-09-30T09:12:44Z"),
            "verbatim, no reformatting"
        );
        assert_eq!(reloaded.source.as_deref(), Some("import"));
        assert_eq!(
            reloaded.to_file_contents(),
            out,
            "and the second write is byte-identical"
        );
    }

    #[test]
    fn triage_config_metadata_entry_time_is_read_not_reconstructed() {
        let mut issue = legacy_issue();
        record_entry(&mut issue, "2026-09-30T09:12:44Z", None);
        assert_eq!(
            entry_time(&issue),
            EntryTime::Recorded("2026-09-30T09:12:44Z")
        );
        assert_eq!(entry_source(&issue), None, "source stays optional");
    }

    #[test]
    fn triage_config_metadata_falls_back_to_unknown_and_never_to_updated() {
        // T003 / R4. The fixture is old enough that `created` and `updated` would
        // both produce a confident, wrong age — the issue is not *in the queue*
        // and has been for however long the file says it has existed.
        let issue = legacy_issue();
        assert_eq!(issue.updated, "2026-01-01", "the bait");
        assert_eq!(issue.created, "2026-01-01");

        // `EntryTime::Unknown` rather than `None`: the caller has to match the
        // variant before it can put a number on screen.
        match entry_time(&issue) {
            EntryTime::Recorded(at) => {
                panic!("a legacy issue has no entry time; `updated` offered `{at}`")
            }
            EntryTime::Unknown => {}
        }
    }

    #[test]
    fn triage_config_metadata_reentry_overwrites_the_stamp_and_the_source() {
        let mut issue = legacy_issue();
        record_entry(&mut issue, "2026-09-01T00:00:00Z", Some("new"));
        // A defer re-enters, so the wait restarts (data-model §5): the *most*
        // recent entry is what the queue measures.
        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("defer"));

        assert_eq!(
            entry_time(&issue),
            EntryTime::Recorded("2026-09-30T09:12:44Z"),
            "the newer stamp wins; keeping the oldest would measure the wrong wait"
        );
        assert_eq!(
            entry_source(&issue),
            Some("defer"),
            "the source describes this entry, not the previous one"
        );

        // ...and an entry with no origin does not inherit the last one's.
        record_entry(&mut issue, "2026-10-01T00:00:00Z", None);
        assert_eq!(entry_source(&issue), None, "no stale origin survives");
    }

    #[test]
    fn triage_config_metadata_recording_never_touches_the_status() {
        // T006: "no accidental status conversion". Entry metadata is frontmatter
        // about *when*; the column is the workflow's to change (C-002).
        let mut issue = legacy_issue();
        let before = issue.status.clone();
        assert_eq!(before, Status::new("todo"));

        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));

        assert_eq!(
            issue.status, before,
            "stamping an entry must not move the issue"
        );
        assert_eq!(
            Issue::parse(&issue.to_file_contents()).unwrap().status,
            before,
            "nor may the round trip"
        );
    }

    #[test]
    fn triage_config_metadata_is_optional_and_never_a_store_row() {
        // C-001: the metadata lives in the issue file. One file, one parse — there
        // is no second structure to keep in step, so a stale in-memory stamp can
        // simply be re-read from the frontmatter it was written to.
        let mut issue = legacy_issue();
        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));
        let written = Issue::parse(&issue.to_file_contents()).unwrap();

        assert_eq!(written.number, 1, "the issue number is unchanged");
        assert_eq!(written.filename, issue.filename, "as is the source file");
        assert_eq!(written.entered_at, issue.entered_at);
    }
}