Skip to main content

tix/
triage.rs

1//! Triage inbox: configuration, entry metadata, and the inbox query.
2//!
3//! Mission `triage-inbox-review-flow-01M3C600`. Triage is a **view over normal
4//! statuses**, not a second workflow: the inbox is whichever columns the optional
5//! `[triage]` section names, membership and category come from the #183 service,
6//! and every decision is an append-only `History` event on the issue file
7//! (C-001, C-002).
8//!
9//! WP01 owns two things:
10//!
11//! - [`config`] — the section's schema and its validation through #183;
12//! - the two optional frontmatter keys that make the view answerable, which is
13//!   what this file holds: when an issue entered, and from where.
14//!
15//! WP02 adds [`inbox`] — the read-only query over those same two things: scope,
16//! entries, age, and counts, all derived from one issue snapshot.
17//!
18//! WP03 adds [`decision`] — the four decisions and their refusals — and
19//! [`history`], the event intent that records one of them. Both stay inside the
20//! same three things: normal statuses, normal relationships, and the existing
21//! `## History` section.
22//!
23//! WP07 adds [`import_policy`] — where a *remote* import places a newly
24//! created file, so an import cannot enter the board through the side door.
25//! It is a policy over the same columns, not a status rule of its own.
26//!
27//! There is no queue store, no row, and no second issue set anywhere in this
28//! module. Everything downstream derives from an issue snapshot.
29
30pub mod batch;
31pub mod config;
32pub mod decision;
33pub mod history;
34pub mod import_policy;
35pub mod inbox;
36
37pub use batch::TriagePlan;
38pub use config::TriageConfig;
39pub use decision::{
40    DecisionKind, DecisionOutcome, DecisionService, DuplicateTarget, Refusal, TriageDecision,
41};
42pub use history::TriageDecisionRecord;
43
44use crate::model::Issue;
45
46/// The entry timestamp to compute queue age from, with its one documented
47/// fallback (T003, FR-005).
48///
49/// Research R4 rejects `created` (it predates the queue) and `updated` (it moves
50/// on every edit) as age proxies, so an issue that recorded no entry time has an
51/// **unknown** age — and this enum says so rather than substituting a date that
52/// measures something else (data-model §2: "must not silently invent an exact
53/// age").
54///
55/// The shape is the point. Returning `None` from an `Option<&str>` would let a
56/// caller `unwrap_or` a borrowed `updated` without appearing to make a decision;
57/// [`EntryTime::Unknown`] has to be matched before any number can be produced, so
58/// the fallback is written down at every call site or the code does not compile.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum EntryTime<'a> {
61    /// The issue's `entered_at`, verbatim.
62    Recorded(&'a str),
63    /// The issue has no `entered_at`. Report the age as unknown.
64    Unknown,
65}
66
67/// The timestamp queue age is derived from.
68///
69/// The **only** supported source of an inbox age; see [`EntryTime`] for why the
70/// fallback is a variant rather than a date.
71#[must_use]
72pub fn entry_time(issue: &Issue) -> EntryTime<'_> {
73    match issue.entered_at.as_deref() {
74        Some(at) => EntryTime::Recorded(at),
75        None => EntryTime::Unknown,
76    }
77}
78
79/// The origin of the issue's current entry, or `None` when it recorded none.
80#[must_use]
81pub fn entry_source(issue: &Issue) -> Option<&str> {
82    issue.source.as_deref()
83}
84
85/// Stamp an issue as having entered triage at `at`, with an optional `source`.
86///
87/// Called on **enter and re-enter** (data-model §2, T003): a defer refreshes
88/// [`Issue::entered_at`], so the queue measures the wait since the issue most
89/// recently joined (data-model §5). The value is therefore overwritten, never
90/// merged — keeping the oldest stamp would answer "how long has this been in the
91/// inbox" with "how long has this existed".
92///
93/// `source` takes exactly what the caller passes, `None` included. A source
94/// describes *this* entry, so carrying a previous entry's origin forward would
95/// credit an entry to something that did not cause it.
96///
97/// Frontmatter only. The `History` event that records the decision belongs to the
98/// decision service (WP03); WP01 owns the metadata and appends nothing to
99/// [`crate::model::History`].
100pub fn record_entry(issue: &mut Issue, at: &str, source: Option<&str>) {
101    issue.entered_at = Some(at.to_string());
102    issue.source = source.map(str::to_string);
103}
104
105/// Stamp an issue that **lands in the inbox**, and leave every other one alone.
106///
107/// The one rule every entry path shares — a create (CLI, TUI form, MCP), an import, a
108/// defer — and the question it answers is the *landing column*, not which flag or policy
109/// produced it: an issue in the inbox has the stamp its age is measured from, and an
110/// issue that never entered the queue gains nothing.
111///
112/// One function rather than an `if` per surface, because that is exactly how the others
113/// drifted: the CLI's create core had a copy and the TUI's form did not, and the
114/// import's copy tested `Mapping::routed` — a *policy* notion — so an import under
115/// `--triage-policy remote` (or a default whose remote-mapped column happens to be the
116/// entry column) landed in the queue unstamped, and the queue reported `unknown` for an
117/// issue it was listing.
118///
119/// `source` is the origin data-model §2 documents for this path (`new`, `import`,
120/// `moved`, `defer`).
121///
122/// # Errors
123///
124/// Propagates a `[triage]` section the board cannot satisfy — the same refusal
125/// [`inbox::scope`] gives every other triage path.
126pub fn record_entry_if_inbox(
127    config: &crate::config::Config,
128    issue: &mut Issue,
129    source: &str,
130) -> crate::error::Result<()> {
131    if let Some(scope) = inbox::scope(config)? {
132        let landing = issue.status.display();
133        if landing == scope.entry_status || landing == scope.deferred_status {
134            record_entry(issue, &crate::model::utc_now_rfc3339(), Some(source));
135        }
136    }
137    Ok(())
138}
139
140/// Stamp a **status change** that landed in the inbox, reporting whether it wrote
141/// a stamp.
142///
143/// The fourth entry path (tix #1489): a `tix move` or `tix edit --status` into an
144/// inbox column is an entry like any other — the issue is now in the queue, and the
145/// queue's age column needs a stamp to measure the wait from. The origin word is
146/// `moved`, added to data-model §2's vocabulary for exactly this: `new` would be
147/// false for an issue that has existed for a year, and the design's rule is that a
148/// missing answer is `unknown` rather than an invented one.
149///
150/// Must be called **after** the status change is applied, since the landing column
151/// is what decides whether the stamp is due — and only when the change actually
152/// wrote something, so a same-column no-op does not restart the wait.
153///
154/// Returns `true` when the entry metadata changed, which is the caller's signal
155/// that the stamp needs its own write (the status seam already wrote the move).
156///
157/// # Errors
158///
159/// Propagates the same `[triage]` refusal [`record_entry_if_inbox`] does.
160pub fn record_move_entry(
161    config: &crate::config::Config,
162    issue: &mut Issue,
163) -> crate::error::Result<bool> {
164    let before = (issue.entered_at.clone(), issue.source.clone());
165    record_entry_if_inbox(config, issue, "moved")?;
166    Ok((issue.entered_at.clone(), issue.source.clone()) != before)
167}
168
169#[cfg(test)]
170mod tests {
171    #[allow(clippy::wildcard_imports)]
172    use super::*;
173    use crate::model::{Issue, Status};
174
175    /// Every key the model always writes, plus nothing triage-specific: the shape
176    /// of every issue file that exists today (research R1).
177    const LEGACY: &str = "---\nnumber: 1\ntitle: Legacy\nstatus: todo\nlabels: []\n\
178                         priority: medium\nfiles: []\ncreated: 2026-01-01\n\
179                         updated: 2026-01-01\n---\n\nBody text.";
180
181    fn legacy_issue() -> Issue {
182        Issue::parse(LEGACY).expect("the baseline fixture parses")
183    }
184
185    // Every WP01 test is named `triage_config_*`, including these, because the
186    // work package's gate is `cargo test triage_config` and it must select the
187    // whole work package — entry metadata included.
188
189    #[test]
190    fn triage_config_metadata_is_absent_on_a_legacy_issue_and_stays_that_way() {
191        let issue = legacy_issue();
192        assert_eq!(issue.entered_at, None, "no queue, no stamp");
193        assert_eq!(issue.source, None, "and no origin");
194
195        let out = issue.to_file_contents();
196        assert!(
197            !out.contains("entered_at") && !out.contains("source:"),
198            "an issue that never entered triage must not gain a key (C-001):\n{out}"
199        );
200        assert_eq!(out, LEGACY, "so a legacy file round-trips byte-identically");
201    }
202
203    #[test]
204    fn triage_config_metadata_round_trips_through_frontmatter() {
205        let mut issue = legacy_issue();
206        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("import"));
207
208        let out = issue.to_file_contents();
209        assert!(
210            out.contains("entered_at: 2026-09-30T09:12:44Z"),
211            "the stamp reaches the file:\n{out}"
212        );
213        assert!(
214            out.contains("source: import"),
215            "and so does the origin:\n{out}"
216        );
217
218        let reloaded = Issue::parse(&out).expect("re-parses");
219        assert_eq!(
220            reloaded.entered_at.as_deref(),
221            Some("2026-09-30T09:12:44Z"),
222            "verbatim, no reformatting"
223        );
224        assert_eq!(reloaded.source.as_deref(), Some("import"));
225        assert_eq!(
226            reloaded.to_file_contents(),
227            out,
228            "and the second write is byte-identical"
229        );
230    }
231
232    #[test]
233    fn triage_config_metadata_entry_time_is_read_not_reconstructed() {
234        let mut issue = legacy_issue();
235        record_entry(&mut issue, "2026-09-30T09:12:44Z", None);
236        assert_eq!(
237            entry_time(&issue),
238            EntryTime::Recorded("2026-09-30T09:12:44Z")
239        );
240        assert_eq!(entry_source(&issue), None, "source stays optional");
241    }
242
243    #[test]
244    fn triage_config_metadata_falls_back_to_unknown_and_never_to_updated() {
245        // T003 / R4. The fixture is old enough that `created` and `updated` would
246        // both produce a confident, wrong age — the issue is not *in the queue*
247        // and has been for however long the file says it has existed.
248        let issue = legacy_issue();
249        assert_eq!(issue.updated, "2026-01-01", "the bait");
250        assert_eq!(issue.created, "2026-01-01");
251
252        // `EntryTime::Unknown` rather than `None`: the caller has to match the
253        // variant before it can put a number on screen.
254        match entry_time(&issue) {
255            EntryTime::Recorded(at) => {
256                panic!("a legacy issue has no entry time; `updated` offered `{at}`")
257            }
258            EntryTime::Unknown => {}
259        }
260    }
261
262    #[test]
263    fn triage_config_metadata_reentry_overwrites_the_stamp_and_the_source() {
264        let mut issue = legacy_issue();
265        record_entry(&mut issue, "2026-09-01T00:00:00Z", Some("new"));
266        // A defer re-enters, so the wait restarts (data-model §5): the *most*
267        // recent entry is what the queue measures.
268        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("defer"));
269
270        assert_eq!(
271            entry_time(&issue),
272            EntryTime::Recorded("2026-09-30T09:12:44Z"),
273            "the newer stamp wins; keeping the oldest would measure the wrong wait"
274        );
275        assert_eq!(
276            entry_source(&issue),
277            Some("defer"),
278            "the source describes this entry, not the previous one"
279        );
280
281        // ...and an entry with no origin does not inherit the last one's.
282        record_entry(&mut issue, "2026-10-01T00:00:00Z", None);
283        assert_eq!(entry_source(&issue), None, "no stale origin survives");
284    }
285
286    #[test]
287    fn triage_config_metadata_recording_never_touches_the_status() {
288        // T006: "no accidental status conversion". Entry metadata is frontmatter
289        // about *when*; the column is the workflow's to change (C-002).
290        let mut issue = legacy_issue();
291        let before = issue.status.clone();
292        assert_eq!(before, Status::new("todo"));
293
294        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));
295
296        assert_eq!(
297            issue.status, before,
298            "stamping an entry must not move the issue"
299        );
300        assert_eq!(
301            Issue::parse(&issue.to_file_contents()).unwrap().status,
302            before,
303            "nor may the round trip"
304        );
305    }
306
307    #[test]
308    fn triage_config_metadata_is_optional_and_never_a_store_row() {
309        // C-001: the metadata lives in the issue file. One file, one parse — there
310        // is no second structure to keep in step, so a stale in-memory stamp can
311        // simply be re-read from the frontmatter it was written to.
312        let mut issue = legacy_issue();
313        record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));
314        let written = Issue::parse(&issue.to_file_contents()).unwrap();
315
316        assert_eq!(written.number, 1, "the issue number is unchanged");
317        assert_eq!(written.filename, issue.filename, "as is the source file");
318        assert_eq!(written.entered_at, issue.entered_at);
319    }
320}