Skip to main content

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}