tix/mentions/mod.rs
1//! Durable `@handle` and `@email` mentions.
2//!
3//! A *mention* is a person-addressed token inside an issue's title or body: `@herbert-maschke`
4//! (the username slug the assignee storage segment uses) or `@thyseus@pm.me` (a full email).
5//! Every write that persists issue content reconciles the mentions it contains against a
6//! per-target store ([`store`]), and the mentioned target is shown their pending mentions when
7//! they next open the issue.
8//!
9//! The layers, in the order data flows through them:
10//!
11//! 1. [`scan`] finds mention tokens, skipping fenced code blocks and inline code spans.
12//! 2. [`anchor`] turns an occurrence into a **content-addressed fingerprint** — the identity
13//! that survives a mention being inserted before or after it.
14//! 3. [`reconcile`] compares the fingerprints present in the written content with the ones
15//! already recorded and produces the new pending set.
16//! 4. [`store`] reads and writes the per-target TOML files.
17//!
18//! Provenance: OpenSpec change `issue-mentions` (`proposal.md`, `design.md`, and
19//! `specs/issue-mentions/spec.md` — the requirements this module implements).
20
21pub mod anchor;
22pub mod identity;
23pub mod reconcile;
24pub mod scan;
25pub mod store;
26
27use std::collections::BTreeMap;
28
29use serde::{Deserialize, Serialize};
30
31/// Whether the target has seen an occurrence.
32///
33/// The vocabulary is deliberately two words. "Ignored" is **not** a third state: ignoring a
34/// mention leaves it `unread`, which is exactly what makes it show again the next time the
35/// issue is opened (design D5 — the store holds the live pending set, not an audit log).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum MentionState {
39 /// Not yet acknowledged; the target sees it when they open the issue.
40 Unread,
41 /// Acknowledged; never surfaced as pending again.
42 Read,
43}
44
45/// One mention occurrence awaiting (or having received) its target's attention.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct Mention {
48 /// Addressable id (`M` + 8 Crockford base32 characters), used by `tix mentions read
49 /// <id>` and by the TUI's action keys.
50 pub id: String,
51 /// The [`anchor::fingerprint`] that identifies this occurrence across edits. Two
52 /// occurrences with the same key are interchangeable by construction.
53 pub key: String,
54 /// The mentioned issue's human-facing number.
55 pub issue_number: u32,
56 /// The mentioned issue's canonical id, so a record survives a file rename.
57 pub issue_id: String,
58 /// Store-relative path of the mentioned issue, as of the last reconciliation.
59 pub issue_file: String,
60 /// The mention body exactly as written: a handle or a full email.
61 pub body: String,
62 /// The containing line, trimmed — what the eye needs to recognise the mention.
63 pub context: String,
64 /// RFC 3339 UTC, when the occurrence was first recorded.
65 pub created: String,
66 /// Whether the target has seen it.
67 pub state: MentionState,
68 /// Keys this build does not know. Preserved on rewrite so a hand-edited file keeps its
69 /// extra annotations (design D4).
70 #[serde(flatten)]
71 pub extra: BTreeMap<String, toml::Value>,
72}
73
74impl Mention {
75 /// Whether this occurrence is still pending (the target has not acknowledged it).
76 #[must_use]
77 pub fn is_unread(&self) -> bool {
78 self.state == MentionState::Unread
79 }
80}
81
82/// One target's mention file (`docs/mentions/<slug>.toml`).
83///
84/// The file is a **table with one `mention` array of tables**, so a hand-edited file with its
85/// own top-level keys keeps them (the same preservation rule as [`Mention::extra`]).
86#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
87pub struct MentionFile {
88 /// The target's mentions, in the order they appear in the issue(s).
89 #[serde(default, rename = "mention", skip_serializing_if = "Vec::is_empty")]
90 pub mentions: Vec<Mention>,
91 /// Unknown top-level keys, preserved on rewrite.
92 #[serde(flatten)]
93 pub extra: BTreeMap<String, toml::Value>,
94}
95
96impl MentionFile {
97 /// Wrap an explicit list of mentions.
98 #[must_use]
99 pub fn new(mentions: Vec<Mention>) -> Self {
100 MentionFile {
101 mentions,
102 extra: BTreeMap::new(),
103 }
104 }
105
106 /// Whether the document holds nothing at all — the state in which its file is removed
107 /// rather than written empty.
108 #[must_use]
109 pub fn is_empty(&self) -> bool {
110 self.mentions.is_empty() && self.extra.is_empty()
111 }
112}
113
114/// The parts of an issue a reconciliation needs.
115///
116/// A struct rather than a `&Issue` so this module does not depend on the model — the store's
117/// call site projects the issue into these five values, and a test can too.
118#[derive(Debug, Clone, Copy)]
119pub struct IssueContent<'a> {
120 /// The issue's canonical id (never recycled), or its filename stem when it has none.
121 pub id: &'a str,
122 /// The issue's human-facing number.
123 pub number: u32,
124 /// Store-relative path of the issue file.
125 pub file: &'a str,
126 /// The issue title.
127 pub title: &'a str,
128 /// The issue body (including any `## Comments` section).
129 pub body: &'a str,
130}
131
132/// An occurrence found in an issue, with the fingerprint that identifies it.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct Located {
135 /// The occurrence's fingerprint input.
136 pub parsed: reconcile::Parsed,
137 /// Where it was found.
138 pub occurrence: scan::Occurrence,
139}
140
141/// Every mention occurrence in `content`, with its fingerprint, in document order.
142#[must_use]
143pub fn collect(content: IssueContent<'_>) -> Vec<Located> {
144 scan::occurrences(content.title, content.body)
145 .into_iter()
146 .map(|occurrence| {
147 let key = anchor::fingerprint(
148 content.id,
149 content.number,
150 &occurrence.body,
151 &occurrence.line_text,
152 occurrence.token_col,
153 );
154 Located {
155 parsed: reconcile::Parsed {
156 body: occurrence.body.clone(),
157 key,
158 context: occurrence.context.clone(),
159 },
160 occurrence,
161 }
162 })
163 .collect()
164}
165
166/// Where an occurrence with `key` sits in the issue **now**, if it is still there.
167///
168/// The location is recomputed from the content rather than stored, because the fingerprint is
169/// the identity: a stored line number would be stale the moment the issue was edited above the
170/// mention.
171#[must_use]
172pub fn locate(content: IssueContent<'_>, key: &str) -> Option<scan::Occurrence> {
173 collect(content)
174 .into_iter()
175 .find(|located| located.parsed.key == key)
176 .map(|located| located.occurrence)
177}
178
179#[cfg(test)]
180mod tests {
181 #[allow(clippy::wildcard_imports)]
182 use super::*;
183
184 fn sample() -> Mention {
185 Mention {
186 id: "M1A2B3C4".to_string(),
187 key: "0f1e2d3c4b5a6978".to_string(),
188 issue_number: 42,
189 issue_id: "1A2B3C4D".to_string(),
190 issue_file: "herbert-maschke/task/todo/2026-10-09-fix.md".to_string(),
191 body: "herbert-maschke".to_string(),
192 context: "blocked on @herbert-maschke's review".to_string(),
193 created: "2026-10-10T09:31:00Z".to_string(),
194 state: MentionState::Unread,
195 extra: BTreeMap::new(),
196 }
197 }
198
199 #[test]
200 fn a_record_round_trips_through_toml() {
201 let file = MentionFile::new(vec![sample()]);
202 let text = toml::to_string(&file).expect("serialize");
203 let back: MentionFile = toml::from_str(&text).expect("deserialize");
204 assert_eq!(back, file);
205 assert!(text.contains("[[mention]]"), "{text}");
206 assert!(text.contains("state = \"unread\""), "{text}");
207 }
208
209 #[test]
210 fn an_extra_hand_written_key_survives_a_rewrite() {
211 // A human added a note to the record and a key of their own to the document.
212 let text = r#"
213note = "kept"
214
215[[mention]]
216id = "M1A2B3C4"
217key = "0f1e2d3c4b5a6978"
218issue_number = 42
219issue_id = "1A2B3C4D"
220issue_file = "a/b/c.md"
221body = "herbert-maschke"
222context = "@herbert-maschke"
223created = "2026-10-10T09:31:00Z"
224state = "unread"
225reviewer_note = "asked in standup"
226"#;
227 let parsed: MentionFile = toml::from_str(text).expect("deserialize");
228 assert_eq!(
229 parsed.extra.get("note"),
230 Some(&toml::Value::String("kept".to_string()))
231 );
232 assert_eq!(
233 parsed.mentions[0].extra.get("reviewer_note"),
234 Some(&toml::Value::String("asked in standup".to_string()))
235 );
236 // Rewriting keeps both the document-level and the record-level extra key.
237 let rewritten = toml::to_string(&parsed).expect("serialize");
238 assert!(rewritten.contains("note = \"kept\""), "{rewritten}");
239 assert!(
240 rewritten.contains("reviewer_note = \"asked in standup\""),
241 "{rewritten}"
242 );
243 }
244
245 #[test]
246 fn read_and_unread_are_distinguishable_and_serialize_lowercase() {
247 assert!(sample().is_unread());
248 let read = Mention {
249 state: MentionState::Read,
250 ..sample()
251 };
252 assert!(!read.is_unread());
253 let text = toml::to_string(&MentionFile::new(vec![read])).expect("serialize");
254 assert!(text.contains("state = \"read\""), "{text}");
255 }
256}