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}